[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/andevsr/pythonnative/main/src/pythonnative/project/permissions.py [Back]  [Original]

"""Cross-platform permission/capability catalog.

PythonNative apps declare the device capabilities they need in a single,
platform-agnostic ``[permissions]`` table in ``pythonnative.toml``:

```toml
[permissions]
camera = "Scan receipts with your camera."
location_when_in_use = "Show nearby stores."
notifications = true
face_id = "Unlock the app with Face ID."
```

This module maps each high-level capability to the concrete native
artifacts it requires:

- iOS: one or more ``Info.plist`` *usage description* keys (the strings
  shown in the system permission prompt), plus optional
  ``UIBackgroundModes`` entries.
- Android: one or more ```` entries in
  ``AndroidManifest.xml``.

A capability's value may be either a string (used verbatim as the iOS
usage description) or ``true`` (use the capability's
[`default_reason`][pythonnative.project.permissions.Capability]). A value
of ``false`` disables the capability, useful for switching one off
without deleting the line.

The catalog is the single source of truth shared by the iOS and Android
configurators and by ``pn doctor``; adding a capability here is all that
is required to make it declarable.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Mapping, Tuple, Union

PermissionValue = Union[bool, str]
"""Type of a value in the ``[permissions]`` table: a reason string or a bool."""


@dataclass(frozen=True)
class Capability:
    """A declarable device capability and its native requirements.

    Attributes:
        key: The capability name as written in ``[permissions]``
            (e.g., ``"camera"``).
        summary: Human-readable description, shown by ``pn doctor`` and
            the docs.
        ios_usage_keys: ``Info.plist`` keys that receive the usage
            description string (e.g., ``"NSCameraUsageDescription"``).
        android_permissions: Fully-qualified Android permission names
            (e.g., ``"android.permission.CAMERA"``).
        ios_background_modes: ``UIBackgroundModes`` values to add
            (e.g., ``"location"``).
        ios_entitlements: Code-signing entitlement key/value pairs the
            capability requires (e.g., ``aps-environment`` for remote
            push notifications).
        default_reason: Fallback usage description used when the
            capability is declared as ``true`` instead of a string.
        needs_reason: Whether iOS requires a usage description for this
            capability. When ``False`` (e.g., notifications), declaring
            the capability as ``true`` is sufficient and no string is
            needed.
    """

    key: str
    summary: str
    ios_usage_keys: Tuple[str, ...] = ()
    android_permissions: Tuple[str, ...] = ()
    ios_background_modes: Tuple[str, ...] = ()
    ios_entitlements: Tuple[Tuple[str, object], ...] = ()
    default_reason: str = ""
    needs_reason: bool = True


def _cap(*args: object, **kwargs: object) -> Capability:
    return Capability(*args, **kwargs)  # type: ignore[arg-type]


# ======================================================================
# The catalog
# ======================================================================

CAPABILITIES: Dict[str, Capability] = {
    c.key: c
    for c in [
        Capability(
            key="camera",
            summary="Capture photos and video with the device camera.",
            ios_usage_keys=("NSCameraUsageDescription",),
            android_permissions=("android.permission.CAMERA",),
            default_reason="This app uses the camera.",
        ),
        Capability(
            key="microphone",
            summary="Record audio from the microphone.",
            ios_usage_keys=("NSMicrophoneUsageDescription",),
            android_permissions=("android.permission.RECORD_AUDIO",),
            default_reason="This app uses the microphone.",
        ),
        Capability(
            key="photo_library",
            summary="Read photos and videos from the photo library.",
            ios_usage_keys=("NSPhotoLibraryUsageDescription",),
            android_permissions=(
                "android.permission.READ_MEDIA_IMAGES",
                "android.permission.READ_MEDIA_VIDEO",
            ),
            default_reason="This app accesses your photo library.",
        ),
        Capability(
            key="photo_library_add",
            summary="Save photos and videos to the photo library.",
            ios_usage_keys=("NSPhotoLibraryAddUsageDescription",),
            android_permissions=(),
            default_reason="This app saves photos to your library.",
        ),
        Capability(
            key="location_when_in_use",
            summary="Access location while the app is in the foreground.",
            ios_usage_keys=("NSLocationWhenInUseUsageDescription",),
            android_permissions=(
                "android.permission.ACCESS_FINE_LOCATION",
                "android.permission.ACCESS_COARSE_LOCATION",
            ),
            default_reason="This app uses your location.",
        ),
        Capability(
            key="location_always",
            summary="Access location in the foreground and background.",
            ios_usage_keys=(
                "NSLocationAlwaysAndWhenInUseUsageDescription",
                "NSLocationWhenInUseUsageDescription",
            ),
            android_permissions=(
                "android.permission.ACCESS_FINE_LOCATION",
                "android.permission.ACCESS_COARSE_LOCATION",
                "android.permission.ACCESS_BACKGROUND_LOCATION",
            ),
            ios_background_modes=("location",),
            default_reason="This app uses your location, even in the background.",
        ),
        Capability(
            key="contacts",
            summary="Read the device address book.",
            ios_usage_keys=("NSContactsUsageDescription",),
            android_permissions=("android.permission.READ_CONTACTS",),
            default_reason="This app accesses your contacts.",
        ),
        Capability(
            key="calendars",
            summary="Read and write calendar events.",
            ios_usage_keys=("NSCalendarsUsageDescription",),
            android_permissions=(
                "android.permission.READ_CALENDAR",
                "android.permission.WRITE_CALENDAR",
            ),
            default_reason="This app accesses your calendar.",
        ),
        Capability(
            key="reminders",
            summary="Read and write reminders (iOS only).",
            ios_usage_keys=("NSRemindersUsageDescription",),
            android_permissions=(),
            default_reason="This app accesses your reminders.",
        ),
        Capability(
            key="motion",
            summary="Access motion and fitness / activity data.",
            ios_usage_keys=("NSMotionUsageDescription",),
            android_permissions=("android.permission.ACTIVITY_RECOGNITION",),
            default_reason="This app uses motion and fitness data.",
        ),
        Capability(
            key="face_id",
            summary="Authenticate with Face ID / biometrics.",
            ios_usage_keys=("NSFaceIDUsageDescription",),
            android_permissions=("android.permission.USE_BIOMETRIC",),
            default_reason="This app uses Face ID to authenticate you.",
        ),
        Capability(
            key="bluetooth",
            summary="Communicate with nearby Bluetooth devices.",
            ios_usage_keys=("NSBluetoothAlwaysUsageDescription",),
            android_permissions=(
                "android.permission.BLUETOOTH_CONNECT",
                "android.permission.BLUETOOTH_SCAN",
            ),
            default_reason="This app connects to Bluetooth devices.",
        ),
        Capability(
            key="speech_recognition",
            summary="Perform speech recognition (iOS only).",
            ios_usage_keys=("NSSpeechRecognitionUsageDescription",),
            android_permissions=(),
            default_reason="This app uses speech recognition.",
        ),
        Capability(
            key="notifications",
            summary="Show local notifications.",
            ios_usage_keys=(),
            android_permissions=("android.permission.POST_NOTIFICATIONS",),
            needs_reason=False,
        ),
        Capability(
            key="remote_notifications",
            summary="Receive remote (APNs) push notifications on iOS.",
            ios_usage_keys=(),
            android_permissions=("android.permission.POST_NOTIFICATIONS",),
            ios_background_modes=("remote-notification",),
            ios_entitlements=(("aps-environment", "development"),),
            needs_reason=False,
        ),
        Capability(
            key="vibration",
            summary="Trigger haptic feedback / vibration.",
            ios_usage_keys=(),
            android_permissions=("android.permission.VIBRATE",),
            needs_reason=False,
        ),
        Capability(
            key="background_audio",
            summary="Continue playing audio in the background (iOS).",
            ios_usage_keys=(),
            ios_background_modes=("audio",),
            android_permissions=("android.permission.FOREGROUND_SERVICE",),
            needs_reason=False,
        ),
        Capability(
            key="background_fetch",
            summary="Perform periodic background fetches (iOS).",
            ios_usage_keys=(),
            ios_background_modes=("fetch",),
            android_permissions=(),
            needs_reason=False,
        ),
    ]
}
"""Mapping of capability key  [`Capability`][pythonnative.project.permissions.Capability]."""


# Permissions every app gets, regardless of declared capabilities. Both are
# "normal" (install-time) Android permissions that never prompt the user, and
# nearly every PythonNative app needs the network (``fetch``, ``use_query``,
# ``NetInfo``, remote images).
BASE_ANDROID_PERMISSIONS: Tuple[str, ...] = (
    "android.permission.INTERNET",
    "android.permission.ACCESS_NETWORK_STATE",
)
"""Android permissions added to every app (network access)."""


@dataclass
class ResolvedPermissions:
    """The native permission artifacts for a resolved capability set.

    Attributes:
        ios_usage_descriptions: ``Info.plist`` usage-description keys
            mapped to their reason strings.
        ios_background_modes: Ordered, de-duplicated ``UIBackgroundModes``
            values.
        ios_entitlements: Code-signing entitlement keys mapped to their
            values (empty when no capability needs entitlements).
        android_permissions: Ordered, de-duplicated Android permission
            names (including the always-on base set).
    """

    ios_usage_descriptions: Dict[str, str] = field(default_factory=dict)
    ios_background_modes: List[str] = field(default_factory=list)
    ios_entitlements: Dict[str, object] = field(default_factory=dict)
    android_permissions: List[str] = field(default_factory=list)


def unknown_capabilities(keys: object) -> List[str]:
    """Return any declared capability keys that aren't in the catalog.

    Args:
        keys: An iterable of capability key strings.

    Returns:
        The subset of ``keys`` not present in
        [`CAPABILITIES`][pythonnative.project.permissions.CAPABILITIES],
        in input order.
    """
    result: List[str] = []
    for key in keys:
        if key not in CAPABILITIES:
            result.append(str(key))
    return result


def resolve_permissions(
    permissions: Mapping[str, PermissionValue],
    *,
    extra_android_permissions: object = (),
) -> ResolvedPermissions:
    """Resolve a declared capability map into native permission artifacts.

    Args:
        permissions: The ``[permissions]`` table, capability key to a
            reason string or boolean. ``false``/``None`` values are
            skipped (capability disabled).
        extra_android_permissions: Additional raw Android permission
            names (e.g., from ``[android].permissions``) to append.

    Returns:
        A [`ResolvedPermissions`][pythonnative.project.permissions.ResolvedPermissions]
        with iOS usage descriptions, iOS background modes, and the full
        ordered Android permission list (base set first).

    Raises:
        ValueError: If an unknown capability key is present. Validate
            earlier with
            [`unknown_capabilities`][pythonnative.project.permissions.unknown_capabilities]
            for a friendlier error.
    """
    resolved = ResolvedPermissions()
    android: List[str] = list(BASE_ANDROID_PERMISSIONS)
    background: List[str] = []

    for key, value in permissions.items():
        if key not in CAPABILITIES:
            raise ValueError(f"Unknown capability: {key!r}")
        if value is False or value is None:
            continue
        cap = CAPABILITIES[key]
        reason = value if isinstance(value, str) and value.strip() else cap.default_reason
        for plist_key in cap.ios_usage_keys:
            # First declaration wins for a shared key (e.g. location_*).
            resolved.ios_usage_descriptions.setdefault(plist_key, reason)
        for mode in cap.ios_background_modes:
            if mode not in background:
                background.append(mode)
        for entitlement_key, entitlement_value in cap.ios_entitlements:
            resolved.ios_entitlements.setdefault(entitlement_key, entitlement_value)
        for perm in cap.android_permissions:
            if perm not in android:
                android.append(perm)

    for perm in extra_android_permissions:
        if perm and perm not in android:
            android.append(str(perm))

    resolved.ios_background_modes = background
    resolved.android_permissions = android
    return resolved


def describe_catalog() -> str:
    """Return a multi-line, human-readable listing of all capabilities.

    Used by ``pn doctor`` / docs tooling to show what can be declared.
    """
    lines: List[str] = []
    for key in sorted(CAPABILITIES):
        cap = CAPABILITIES[key]
        lines.append(f"  {key:

Web Proxy Viewer  |  New URL  |  Original Page