diff --git a/CHANGELOG.md b/CHANGELOG.md index fbeddd0843..9ba23e75e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,15 @@ * Themes built from a seed color, including the default theme, now have neutral surfaces: white in light mode and near-black (`#121212`) in dark mode, with neutral greys for containers, outlines and text on surfaces. The seed still drives accent colors. To keep the seed-tinted Material 3 look, set `page.theme = ft.Theme(surfaces=ft.ThemeSurfaces.TONAL)` (and the same for `page.dark_theme`). See [Default theme surfaces are now neutral](https://flet.dev/docs/updates/breaking-changes/v1-1-0/neutral-default-surfaces) ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * The default dark splash and loading screen color of web apps is now `#121212` (was `#222222`), to match the new dark theme surface ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Custom [`flet build` templates](/docs/publish#build-template) (`--template`) must read the build's settings from `cookiecutter._options` and the app's `pyproject.toml` from `cookiecutter._pyproject` (were `cookiecutter.options` and `cookiecutter.pyproject`). Cookiecutter passes these private variables to the template unchanged, so numbers keep their types and values aren't rendered as Jinja ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. +* Custom [`flet build` templates](/docs/publish#build-template) (`--template`) must read the iOS export options from `cookiecutter._options.ios_export_options` (was `cookiecutter.ios_export_options`), otherwise they are left out of `exportOptions.plist` without an error ([#7036](https://github.com/flet-dev/flet/pull/7036)) by @ndonkoHenri. * The Flet desktop client is no longer built for Debian 10 on ARM64: `flet run` on an ARM64 Linux system with glibc older than 2.31 (Debian 10 and other glibc 2.28 distros) now stops with an error naming the glibc it needs. Debian 10 on x86-64 is still supported by @FeodorFitsner. ### New features * Add `Page.get_package_info()`, returning `ft.PackageInfo` with the running app's name, package name, version, build number, build signature, installer store, and install/update times. A `flet build` app gets the `--build-version` and `--build-number` it was built with, so it no longer needs its own copy of the version ([#6950](https://github.com/flet-dev/flet/issues/6950)) by @FeodorFitsner. * `flet build`: permission bundles can be set per platform with `[tool.flet.].permissions`, which replaces `[tool.flet].permissions` for that platform (`[]` applies none) by @FeodorFitsner. -* Add `InteractiveViewer.get_scale()`, `get_transform()`, and - `on_transform_changed` to expose the effective zoom scale, - translation, and transformation matrix, including reliable - final-state notifications after consecutive interactions - ([#6938](https://github.com/flet-dev/flet/issues/6938), - [#6939](https://github.com/flet-dev/flet/pull/6939)) - by @ada-koki. +* Add `InteractiveViewer.get_scale()`, `get_transform()`, and `on_transform_changed` to expose the effective zoom scale, translation, and transformation matrix, including reliable final-state notifications after consecutive interactions ([#6938](https://github.com/flet-dev/flet/issues/6938), [#6939](https://github.com/flet-dev/flet/pull/6939)) by @ada-koki. * Add the `RefreshIndicator` control for pull-to-refresh: pulling its scrollable `content` down calls the async `on_refresh` handler and shows a progress indicator until it returns, and `show()` refreshes without a pull, for desktop and web. See [RefreshIndicator](https://flet.dev/docs/controls/refreshindicator) ([#6997](https://github.com/flet-dev/flet/pull/6997)) by @osamalzabidi. - * Add the `flet-shadcn-ui` extension: controls styled after [shadcn/ui](https://ui.shadcn.com), built on the Flutter [`shadcn_ui`](https://pub.dev/packages/shadcn_ui) package, with `Theme` color schemes and the [Lucide](https://lucide.dev) icon set (`LucideIcons`). It includes `Button`, `IconButton`, `Badge`, `Alert`, `Avatar`, `Card`, `Breadcrumb`, `Separator`, `Progress`, `Input`, `Textarea`, `Checkbox`, `Switch`, `Slider`, `RadioGroup`, `Select`, `InputOTP`, `Calendar`, `DatePicker`, `DateRangePicker`, `TimePicker`, `Tabs`, `Accordion`, `ResizablePanelGroup`, `Tooltip`, `Popover`, `ContextMenu`, `Menubar`, `Table`, `Dialog`, `Sheet`, `Toast` and `Sonner`; value controls support `label`, `description` and `error_text` ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner. * Add the `flet-notifications` extension: a `Notifications` service that shows, schedules and cancels local notifications on Android, iOS, macOS, Windows, Linux and Web, wrapping the [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications) plugin, with permission requests, Android channels, rich styles, inline-reply actions and typed tap and action handlers ([#6948](https://github.com/flet-dev/flet/issues/6948), [#6999](https://github.com/flet-dev/flet/pull/6999)) by @Nwokike. * `flet build`: add `[tool.flet.android].res_dir` to bundle the app's own Android resources, such as a notification icon or sound that code looks up by name; they are kept in release builds. See [Resources](/docs/publish/android#resources) ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. @@ -36,6 +30,8 @@ ### Improvements +* Bump the bundled Flutter to [3.47.8](https://github.com/flutter/flutter/blob/stable/CHANGELOG.md#3478) (from 3.44.8). Android builds use Gradle 9.3.1, Android Gradle Plugin 8.13.2 and Kotlin 2.3.21 ([#6916](https://github.com/flet-dev/flet/pull/6916), [#7023](https://github.com/flet-dev/flet/pull/7023)) by @ndonkoHenri. + **Compatibility:** Desktop apps and the desktop app that `flet run` opens now render with Impeller, Flutter 3.47's default desktop renderer, instead of Skia. Use `flet build --no-impeller` (or `impeller = false` under `[tool.flet]`), `flet run --no-impeller` or the `FLET_NO_IMPELLER` environment variable to render with Skia; see [Renderer](/docs/publish#renderer). Android plugins that still use `jcenter()` or other APIs removed in Gradle 9 no longer build. `flet build` downloads Flutter 3.47.7 on first use. * The prebuilt Windows desktop client (`flet.exe` and its DLLs) that `flet run` downloads is now code-signed, so it starts with Windows Smart App Control turned on ([#7011](https://github.com/flet-dev/flet/issues/7011)) by @FeodorFitsner. * Web apps served by Flet, such as with `flet run --web`, send their text and WebAssembly files gzip-compressed to browsers that accept it, so `main.dart.wasm`, `main.dart.js`, CanvasKit and fonts download about 3x smaller ([#6266](https://github.com/flet-dev/flet/issues/6266), [#7026](https://github.com/flet-dev/flet/pull/7026)) by @ndonkoHenri. * `flet pack` warns about each extension the app imports that the prebuilt Flet client doesn't include, since its controls show as unknown controls in the packed app ([#6999](https://github.com/flet-dev/flet/pull/6999)) by @ndonkoHenri. @@ -51,11 +47,6 @@ * Add `FletApp.platform_brightness` to preview an embedded app as though its device were in light or dark mode: an app on the `SYSTEM` theme follows it, `page.platform_brightness` reports it and `on_platform_brightness_change` fires by @FeodorFitsner. * Add `Theme.markdown_theme`: a default `MarkdownStyleSheet` for every `Markdown`, with a control's own `md_style_sheet` applied on top property by property by @FeodorFitsner. * Add `PopupMenuTheme.border_radius` to set the default hover/splash corner radius of every `PopupMenuButton` by @FeodorFitsner. - -* Bump the bundled Flutter to [3.47.8](https://github.com/flutter/flutter/blob/stable/CHANGELOG.md#3478) (from 3.44.8). Android builds use Gradle 9.3.1, Android Gradle Plugin 8.13.2 and Kotlin 2.3.21 ([#6916](https://github.com/flet-dev/flet/pull/6916), [#7023](https://github.com/flet-dev/flet/pull/7023)) by @ndonkoHenri. - - **Compatibility:** Desktop apps and the desktop app that `flet run` opens now render with Impeller, Flutter 3.47's default desktop renderer, instead of Skia. Use `flet build --no-impeller` (or `impeller = false` under `[tool.flet]`), `flet run --no-impeller` or the `FLET_NO_IMPELLER` environment variable to render with Skia; see [Renderer](/docs/publish#renderer). Android plugins that still use `jcenter()` or other APIs removed in Gradle 9 no longer build. `flet build` downloads Flutter 3.47.7 on first use. - * Add `Theme.surfaces` and the `ThemeSurfaces` enum (`NEUTRAL`, `TONAL`) to choose whether surface colors are neutral or tinted with the seed color ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Add `flet test --update-failed-goldens` (`FLET_TEST_GOLDEN=failed`): compares screenshots as usual and overwrites only golden images that are missing or no longer match, so unchanged goldens stay untouched ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Add `bgcolor` to `FletTestApp.take_page_controls_screenshot()`, `assert_control_screenshot()` and `wrap_page_controls_in_screenshot()` to paint an opaque background behind the captured controls instead of a transparent one ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner. @@ -66,6 +57,9 @@ ### Bug fixes +* Fix `flet build macos` failing with `Scheme Runner is not currently configured for the build action` when the product or artifact name contains `&` ([#7036](https://github.com/flet-dev/flet/pull/7036)) by @ndonkoHenri. +* Fix `flet build` rejecting `--permissions biometric`, although the `biometric` permission bundle works from `pyproject.toml` ([#7036](https://github.com/flet-dev/flet/pull/7036)) by @ndonkoHenri. +* Fix `flet build ipa` failing to export when a `[tool.flet.ios.export_options]` value contains `<`, `>` or `&`, such as `thinning = ""`, and writing numbers as strings ([#7036](https://github.com/flet-dev/flet/pull/7036)) by @ndonkoHenri. * Fix `flet build` for Android failing when a `[tool.flet.android.meta_data]` value or a `[tool.flet.android.provider]` attribute contains `&`, `<` or `"`, which were written into the manifest unescaped ([#7031](https://github.com/flet-dev/flet/pull/7031)) by @ndonkoHenri. * Fix `flet build` for Android failing when `[tool.flet.android.manifest_application]` sets `label`, `name`, `icon` or `enableOnBackInvokedCallback`, which wrote the attribute twice instead of replacing Flet's value ([#7031](https://github.com/flet-dev/flet/pull/7031)) by @ndonkoHenri. * Fix `flet build` failing with no explanation unless run with `-vv` when a step reported its error on stdout, such as a `SyntaxError` in an app file ([#7029](https://github.com/flet-dev/flet/issues/7029), [#7031](https://github.com/flet-dev/flet/pull/7031)) by @ndonkoHenri. @@ -101,11 +95,8 @@ * Fix `InteractiveViewer` swallowing taps meant for its content when `pan_enabled` and `scale_enabled` are both off: it no longer installs gesture recognizers it can't use; programmatic zoom/pan still works by @FeodorFitsner. * An embedded app (`FletApp`) no longer takes its host's Cupertino colors: `CupertinoFilledButton`, `CupertinoButton` and adaptive buttons on iOS/macOS painted with the host app's primary color instead of the embedded app's own. An embedded page now carries a Cupertino theme derived from its own Material theme, as a top-level app does; top-level apps are unchanged by @FeodorFitsner. * `Button(adaptive=True)` on iOS/macOS now honors `bgcolor`, `color` and the padding of `style`: the Cupertino button it turns into ignored them and painted the theme's primary color by @FeodorFitsner. - * Fix `flet build`, `flet debug` and `flet test` failing for macOS and iOS on Xcode 27, which rejects deployment targets below macOS 12 and iOS 15 ([#6874](https://github.com/flet-dev/flet/issues/6874)) by @ndonkoHenri. - **Compatibility:** Apps built with `flet build` and the macOS desktop client now require macOS 12 or later, and iOS apps require iOS 15 or later. Custom build templates keep building, because Flutter 3.47 raises the deployment targets during the build; apply the same `Podfile` and Xcode project changes to skip that step. See the [macOS 12 and iOS 15 minimum](/docs/updates/breaking-changes/v1-1-0/minimum-macos-12-ios-15) guide. - * Fix Flet web apps using the `hash` route URL strategy returning `index.html` with a `200 OK` for unknown paths instead of a `404` ([#2796](https://github.com/flet-dev/flet/issues/2796), [#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. * Fix `flet.fastapi.app()` and `ft.run(..., export_asgi_app=True)` failing every request with a `500` error when `route_url_strategy` or `web_renderer` is passed as a string, such as `"hash"` ([#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. * Fix `page.login(redirect_to_page=True)` not returning to the page's route with the `hash` route URL strategy or under a `proxy_path` ([#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 299e83ddb0..4e0eafcf88 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -106,6 +106,90 @@ # real offenders here as they are found. ANDROID_DEFAULT_EXTRACT_PACKAGES: list[str] = [] +CROSS_PLATFORM_PERMISSIONS = { + "location": { + "ios_info_plist": { + "NSLocationWhenInUseUsageDescription": "This app uses location service when in use.", # noqa: E501 + "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses location service.", # noqa: E501 + }, + "macos_info_plist": { + "NSLocationUsageDescription": "This app needs access to your location.", # noqa: E501 + }, + "macos_entitlements": { + "com.apple.security.personal-information.location": True + }, + "android_permissions": { + "android.permission.ACCESS_FINE_LOCATION": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "android_features": { + "android.hardware.location.network": False, + "android.hardware.location.gps": False, + }, + }, + "camera": { + "ios_info_plist": { + "NSCameraUsageDescription": "This app uses the camera to capture photos and videos." # noqa: E501 + }, + "macos_info_plist": { + "NSCameraUsageDescription": "This app uses the camera to capture photos and videos." # noqa: E501 + }, + "macos_entitlements": {"com.apple.security.device.camera": True}, + "android_permissions": {"android.permission.CAMERA": True}, + "android_features": { + "android.hardware.camera": False, + "android.hardware.camera.any": False, + "android.hardware.camera.front": False, + "android.hardware.camera.external": False, + "android.hardware.camera.autofocus": False, + }, + }, + "microphone": { + "ios_info_plist": { + "NSMicrophoneUsageDescription": "This app uses microphone to record sounds.", # noqa: E501 + }, + "macos_info_plist": { + "NSMicrophoneUsageDescription": "This app uses microphone to record sounds.", # noqa: E501 + }, + "macos_entitlements": {"com.apple.security.device.audio-input": True}, + # Recording itself only needs RECORD_AUDIO: recordings are written + # to app-scoped storage, which requires no permission. Do not add + # READ/WRITE_EXTERNAL_STORAGE here — Google Play's Photo and Video + # Permissions policy rejects apps that request broad media/storage + # access without a qualifying use case, and this group is opt-in + # for the microphone, not for the user's media library. + "android_permissions": {"android.permission.RECORD_AUDIO": True}, + "android_features": {}, + }, + "photo_library": { + "ios_info_plist": { + "NSPhotoLibraryUsageDescription": "This app saves photos and videos to the photo library." # noqa: E501 + }, + "macos_info_plist": { + "NSPhotoLibraryUsageDescription": "This app saves photos and videos to the photo library." # noqa: E501 + }, + "macos_entitlements": { + "com.apple.security.personal-information.photos-library": True + }, + "android_permissions": { + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True + }, + "android_features": {}, + }, + "biometric": { + "ios_info_plist": { + "NSFaceIDUsageDescription": "This app uses biometrics to authenticate you." # noqa: E501 + }, + "macos_info_plist": { + "NSFaceIDUsageDescription": "This app uses biometrics to authenticate you." # noqa: E501 + }, + "macos_entitlements": {}, + "android_permissions": {}, + "android_features": {}, + }, +} + # Bumped whenever a change to flet-platform-assets alters the pixels it # produces. Both stamps hash it, so upgrading flet-cli regenerates rather # than leaving a user with output from the previous implementation. Nothing @@ -256,90 +340,6 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: }, } - self.cross_platform_permissions = { - "location": { - "ios_info_plist": { - "NSLocationWhenInUseUsageDescription": "This app uses location service when in use.", # noqa: E501 - "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses location service.", # noqa: E501 - }, - "macos_info_plist": { - "NSLocationUsageDescription": "This app needs access to your location.", # noqa: E501 - }, - "macos_entitlements": { - "com.apple.security.personal-information.location": True - }, - "android_permissions": { - "android.permission.ACCESS_FINE_LOCATION": True, - "android.permission.ACCESS_COARSE_LOCATION": True, - "android.permission.ACCESS_BACKGROUND_LOCATION": True, - }, - "android_features": { - "android.hardware.location.network": False, - "android.hardware.location.gps": False, - }, - }, - "camera": { - "ios_info_plist": { - "NSCameraUsageDescription": "This app uses the camera to capture photos and videos." # noqa: E501 - }, - "macos_info_plist": { - "NSCameraUsageDescription": "This app uses the camera to capture photos and videos." # noqa: E501 - }, - "macos_entitlements": {"com.apple.security.device.camera": True}, - "android_permissions": {"android.permission.CAMERA": True}, - "android_features": { - "android.hardware.camera": False, - "android.hardware.camera.any": False, - "android.hardware.camera.front": False, - "android.hardware.camera.external": False, - "android.hardware.camera.autofocus": False, - }, - }, - "microphone": { - "ios_info_plist": { - "NSMicrophoneUsageDescription": "This app uses microphone to record sounds.", # noqa: E501 - }, - "macos_info_plist": { - "NSMicrophoneUsageDescription": "This app uses microphone to record sounds.", # noqa: E501 - }, - "macos_entitlements": {"com.apple.security.device.audio-input": True}, - # Recording itself only needs RECORD_AUDIO: recordings are written - # to app-scoped storage, which requires no permission. Do not add - # READ/WRITE_EXTERNAL_STORAGE here — Google Play's Photo and Video - # Permissions policy rejects apps that request broad media/storage - # access without a qualifying use case, and this group is opt-in - # for the microphone, not for the user's media library. - "android_permissions": {"android.permission.RECORD_AUDIO": True}, - "android_features": {}, - }, - "photo_library": { - "ios_info_plist": { - "NSPhotoLibraryUsageDescription": "This app saves photos and videos to the photo library." # noqa: E501 - }, - "macos_info_plist": { - "NSPhotoLibraryUsageDescription": "This app saves photos and videos to the photo library." # noqa: E501 - }, - "macos_entitlements": { - "com.apple.security.personal-information.photos-library": True - }, - "android_permissions": { - "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True - }, - "android_features": {}, - }, - "biometric": { - "ios_info_plist": { - "NSFaceIDUsageDescription": "This app uses biometrics to authenticate you." # noqa: E501 - }, - "macos_info_plist": { - "NSFaceIDUsageDescription": "This app uses biometrics to authenticate you." # noqa: E501 - }, - "macos_entitlements": {}, - "android_permissions": {}, - "android_features": {}, - }, - } - # create and display build-platform-matrix table self.platform_matrix_table = Table( Column("Command", style="cyan", justify="left"), @@ -782,7 +782,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: action="extend", nargs="+", default=[], - choices=["location", "camera", "microphone", "photo_library"], + choices=list(CROSS_PLATFORM_PERMISSIONS), help="The list of pre-defined cross-platform permissions for iOS, Android " "and macOS builds", ) @@ -1145,425 +1145,94 @@ def setup_template_data(self): title="Impeller", ) - # Explicit `[tool.flet.macos.info]` or `--info-plist` entries override - # this key. - info_plist = ( - {"FLTEnableImpeller": False} - if self.package_platform == "Darwin" and not impeller - else {} - ) - macos_entitlements = { - "com.apple.security.app-sandbox": False, - "com.apple.security.cs.allow-jit": True, - "com.apple.security.cs.allow-unsigned-executable-memory": True, - "com.apple.security.network.client": True, - "com.apple.security.network.server": True, - "com.apple.security.files.user-selected.read-write": True, - } - android_permissions = {"android.permission.INTERNET": True} - android_features = { - "android.software.leanback": False, - "android.hardware.touchscreen": False, - } - # Explicit `[tool.flet.android.meta_data]` or `--android-meta-data` - # entries override this key. - android_meta_data = ( - {"io.flutter.embedding.android.EnableImpeller": "false"} - if not impeller - else {} - ) - android_providers = {} - # Gradle properties for the generated Android project. These were - # hardcoded in the template; the defaults below reproduce them exactly, - # and `[tool.flet.android.gradle_properties]` can override any entry or - # add new ones. The memory settings in particular are too large for some - # CI runners (a standard GitHub-hosted runner has ~7 GB of RAM, less - # than -Xmx8G alone), which previously could not be changed at all. - android_gradle_properties = { - "org.gradle.jvmargs": ( - "-Xmx8G -XX:MaxMetaspaceSize=4G " - "-XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError" - ), - "android.useAndroidX": "true", - # Kotlin's incremental compiler stores plugin sources relative to - # the Android project and fails the build when they sit on another - # Windows drive, e.g. the pub cache on C: and the app on D:. - "kotlin.incremental": "false", - } - # ProGuard/R8 rules for the generated Android project. Like - # gradle_properties above, these were a fixed template file and the - # defaults reproduce it exactly. R8 renames classes in release builds - # while pyjnius resolves them by name, so an app that reaches into a - # bundled AAR needs a keep rule and had no way to add one. - # - # `proguard_rules` appends, since R8 has no directive that undoes a - # keep. Removing a default therefore needs its own switch: - # `proguard_default_rules = false` drops them, which is the only way to - # shed `-keepnames class * { *; }`. Keeping the two separate stops - # users pasting today's defaults into pyproject.toml and silently - # holding them after the defaults change. - keep_defaults = ( - self.get_pyproject("tool.flet.android.proguard_default_rules") is not False - ) - android_proguard_rules = ( - [ - "-keep class com.flet.serious_python_android.** { *; }", - "-keepnames class * { *; }", - ] - if keep_defaults - else [] - ) + android_permissions = self.get_android_permissions() + android_features = self.get_android_features() + android_meta_data = self.get_android_meta_data(impeller) + android_providers = self.get_android_providers() + android_gradle_properties = self.get_android_gradle_properties() + android_proguard_rules = self.get_android_proguard_rules() - # merge values from "--permissions" arg: - for p in self.resolve_permissions(): - if p in self.cross_platform_permissions: - permission_config = self.cross_platform_permissions[p] - info_plist.update( - permission_config.get(f"{self.config_platform}_info_plist", {}) - or permission_config.get("info_plist", {}) - ) - macos_entitlements.update( - self.cross_platform_permissions[p]["macos_entitlements"] - ) - android_permissions.update( - self.cross_platform_permissions[p]["android_permissions"] - ) - android_features.update( - self.cross_platform_permissions[p]["android_features"] - ) + deep_linking = self.get_deep_linking() - info_plist = merge_dict( - info_plist, - ( - self.get_pyproject("tool.flet.macos.info") - if self.package_platform == "Darwin" - else self.get_pyproject("tool.flet.ios.info") - ) - or {}, + target_arch = self.options.target_arch or self.get_platform_setting( + "target_arch" ) - - # parse --info-plist - for p in self.options.info_plist: - i = p.find("=") - if i > -1: - k = p[:i] - v = p[i + 1 :] - info_plist[k] = parse_cli_plist_value(v) - else: - self.cleanup(1, f"Invalid Info.plist option: {p}") - - for key, value in info_plist.items(): - if not is_supported_plist_value(value): + target_arch = ( + target_arch + if isinstance(target_arch, list) + else [target_arch] + if isinstance(target_arch, str) + else [] + ) + if self.package_platform == "Android": + invalid_archs = [ + arch + for arch in target_arch + if arch not in ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM + ] + if invalid_archs: self.cleanup( 1, - "Unsupported Info.plist value type for " - f"{key}: {type(value).__name__}. Supported types are " - "string, boolean, integer, float, dictionary, and arrays " - "containing those values.", + f"Invalid Android architecture(s): {', '.join(invalid_archs)}.\n" + f"Supported: " + f"{', '.join(ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM)}.\n" + f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures", ) - - macos_entitlements = merge_dict( - macos_entitlements, - self.get_pyproject("tool.flet.macos.entitlement") or {}, - ) - - # parse --macos-entitlements - for p in self.options.macos_entitlements: - i = p.find("=") - if i > -1: - macos_entitlements[p[:i]] = parse_cli_plist_value(p[i + 1 :]) - else: - self.cleanup(1, f"Invalid macOS entitlement option: {p}") - - for key, value in macos_entitlements.items(): - if not is_supported_plist_value(value): + python_abis = list(self.python_release.android_abis) + unsupported_archs = [a for a in target_arch if a not in python_abis] + if unsupported_archs: self.cleanup( 1, - "Unsupported macOS entitlement value type for " - f"{key}: {type(value).__name__}. Supported types are " - "string, boolean, integer, float, dictionary, and arrays " - "containing those values.", + f"Architecture(s) not supported by Python " + f"{self.python_release.short}: {', '.join(unsupported_archs)}.\n" + f"Supported: {', '.join(python_abis)}.\n" + f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures", ) + if not target_arch: + # Build only for the ABIs the bundled Python supports. + target_arch = python_abis - android_permissions = merge_dict( - android_permissions, - self.get_pyproject("tool.flet.android.permission") or {}, - ) - - android_gradle_properties = merge_dict( - android_gradle_properties, - self.get_pyproject("tool.flet.android.gradle_properties") or {}, - ) - - android_proguard_rules = android_proguard_rules + [ - str(rule) - for rule in (self.get_pyproject("tool.flet.android.proguard_rules") or []) - ] - - # parse --android-permissions - for p in self.options.android_permissions: - i = p.find("=") - if i > -1: - try: - android_permissions[p[:i]] = parse_cli_bool_value(p[i + 1 :]) - except ValueError: - self.cleanup( - 1, - f"Invalid Android permission option value for {p[:i]}: " - f"{p[i + 1 :]}. Expected true or false.", - ) - else: - self.cleanup(1, f"Invalid Android permission option: {p}") + ios_signing = self.get_ios_signing() - for key, value in android_permissions.items(): - if isinstance(value, bool): - continue - if isinstance(value, dict): - for ak, av in value.items(): - if not isinstance(av, (str, bool, int, float)): - self.cleanup( - 1, - f"Invalid Android permission attribute value for " - f"{key}.{ak}: {type(av).__name__}. " - "Expected string, boolean, or number.", - ) - continue - self.cleanup( - 1, - f"Invalid Android permission value for {key}: " - f"{type(value).__name__}. Expected boolean or inline table.", + if ( + self.target_platform in ["ipa"] + and not ios_signing["provisioning_profile"] + and not self.debug_platform + and not getattr(self, "test_mode", False) + ): + self.warn( + "This build will generate an .xcarchive (Xcode Archive). " + "To produce an .ipa (iOS App Package), please specify " + "a Provisioning Profile.", + title="iOS signing", + panel=True, ) - android_features = merge_dict( - android_features, - self.get_pyproject("tool.flet.android.feature") or {}, - ) - - # parse --android-features - for p in self.options.android_features: - i = p.find("=") - if i > -1: - try: - android_features[p[:i]] = parse_cli_bool_value(p[i + 1 :]) - except ValueError: - self.cleanup( - 1, - f"Invalid Android feature option value for {p[:i]}: " - f"{p[i + 1 :]}. Expected true or false.", - ) - else: - self.cleanup(1, f"Invalid Android feature option: {p}") - - android_meta_data = merge_dict( - android_meta_data, - self.get_pyproject("tool.flet.android.meta_data") or {}, + assert self.flutter_dir + project_description = ( + self.options.description + or self.get_pyproject("project.description") + or self.get_pyproject("tool.poetry.description") + or "" ) - - # parse --android-meta-data - for p in self.options.android_meta_data: - i = p.find("=") - if i > -1: - android_meta_data[p[:i]] = p[i + 1 :] - else: - self.cleanup(1, f"Invalid Android meta-data option: {p}") - - android_providers = merge_dict( - android_providers, - self.get_pyproject("tool.flet.android.provider") or {}, - ) - - def _xml_attr_value(v): - # Android XML expects lowercase booleans. - if isinstance(v, bool): - return "true" if v else "false" - return v - - normalized_providers = {} - for key, value in android_providers.items(): - if value is False or value == {}: - continue - if value is True: - self.cleanup( - 1, - f"Invalid Android provider value for {key}: 'true' is not " - "supported. Use an inline table of attributes, or 'false' " - "to skip.", - ) - if not isinstance(value, dict): - self.cleanup( - 1, - f"Invalid Android provider value for {key}: " - f"{type(value).__name__}. Expected boolean or inline table.", - ) - normalized = {} - for ak, av in value.items(): - if ak == "name": - self.cleanup( - 1, - f"Invalid Android provider attribute for {key}: " - "'name' is reserved and is taken from the table key.", - ) - if ak == "meta_data": - if not isinstance(av, dict): - self.cleanup( - 1, - f"Invalid Android provider meta_data for {key}: " - f"{type(av).__name__}. Expected inline table.", - ) - normalized_meta = {} - for mk, mv in av.items(): - if isinstance(mv, (str, bool, int, float)): - normalized_meta[mk] = _xml_attr_value(mv) - continue - if isinstance(mv, dict): - normalized_attrs = {} - for attr_key, attr_value in mv.items(): - if not isinstance(attr_value, (str, bool, int, float)): - self.cleanup( - 1, - f"Invalid Android provider meta-data " - f"attribute value for " - f"{key}.meta_data.{mk}.{attr_key}: " - f"{type(attr_value).__name__}. " - "Expected string, boolean, or number.", - ) - normalized_attrs[attr_key] = _xml_attr_value(attr_value) - normalized_meta[mk] = normalized_attrs - continue - self.cleanup( - 1, - f"Invalid Android provider meta-data value for " - f"{key}.meta_data.{mk}: {type(mv).__name__}. " - "Expected string, boolean, number, or inline table.", - ) - normalized["meta_data"] = normalized_meta - continue - if not isinstance(av, (str, bool, int, float)): - self.cleanup( - 1, - f"Invalid Android provider attribute value for " - f"{key}.{ak}: {type(av).__name__}. " - "Expected string, boolean, or number.", - ) - normalized[ak] = _xml_attr_value(av) - normalized_providers[key] = normalized - android_providers = normalized_providers - - deep_linking = self.get_deep_linking() - - target_arch = self.options.target_arch or self.get_platform_setting( - "target_arch" - ) - target_arch = ( - target_arch - if isinstance(target_arch, list) - else [target_arch] - if isinstance(target_arch, str) - else [] - ) - if self.package_platform == "Android": - invalid_archs = [ - arch - for arch in target_arch - if arch not in ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM - ] - if invalid_archs: - self.cleanup( - 1, - f"Invalid Android architecture(s): {', '.join(invalid_archs)}.\n" - f"Supported: " - f"{', '.join(ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM)}.\n" - f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures", - ) - python_abis = list(self.python_release.android_abis) - unsupported_archs = [a for a in target_arch if a not in python_abis] - if unsupported_archs: - self.cleanup( - 1, - f"Architecture(s) not supported by Python " - f"{self.python_release.short}: {', '.join(unsupported_archs)}.\n" - f"Supported: {', '.join(python_abis)}.\n" - f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures", - ) - if not target_arch: - # Build only for the ABIs the bundled Python supports. - target_arch = python_abis - - ios_export_method = ( - self.options.ios_export_method - or self.get_pyproject("tool.flet.ios.export_method") - or "debugging" - ) - - ios_export_method_opts = ( - self.get_pyproject("tool.flet.ios.export_methods").get(ios_export_method) - if self.get_pyproject("tool.flet.ios.export_methods") - else {} - ) or {} - - ios_provisioning_profile = ( - self.options.ios_provisioning_profile - or ios_export_method_opts.get("provisioning_profile") - or self.get_pyproject("tool.flet.ios.provisioning_profile") - ) - - ios_signing_certificate = ( - self.options.ios_signing_certificate - or ios_export_method_opts.get("signing_certificate") - or self.get_pyproject("tool.flet.ios.signing_certificate") - ) - - ios_export_options = ( - ios_export_method_opts.get("export_options") - or self.get_pyproject("tool.flet.ios.export_options") - or {} - ) - - ios_team_id = ( - self.options.ios_team_id - or ios_export_method_opts.get("team_id") - or self.get_pyproject("tool.flet.ios.team_id") - ) - - if ( - self.target_platform in ["ipa"] - and not ios_provisioning_profile - and not self.debug_platform - and not getattr(self, "test_mode", False) - ): - self.warn( - "This build will generate an .xcarchive (Xcode Archive). " - "To produce an .ipa (iOS App Package), please specify " - "a Provisioning Profile.", - title="iOS signing", - panel=True, - ) - - assert self.flutter_dir - project_description = ( - self.options.description - or self.get_pyproject("project.description") - or self.get_pyproject("tool.poetry.description") - or "" - ) - linux_categories = self.escape_linux_desktop_categories(["Utility"]) - if self.target_platform == "linux": - try: - # Desktop entry values are escaped here rather than in the template: - # the rules differ per key and getting them wrong yields an entry the - # desktop environment silently discards. - linux_categories = self.escape_linux_desktop_categories( - self.options.linux_categories - or self.get_pyproject("tool.flet.linux.categories") - or ["Utility"] - ) - except ValueError as e: - self.cleanup(1, f"Invalid tool.flet.linux.categories: {e}") - # Resolved before the context, because the PWA background falls - # through to it. - splash = self._resolve_splash() - org_name = ( - self.options.org_name or self.get_platform_setting("org") or "com.flet" + linux_categories = self.escape_linux_desktop_categories(["Utility"]) + if self.target_platform == "linux": + try: + # Desktop entry values are escaped here rather than in the template: + # the rules differ per key and getting them wrong yields an entry the + # desktop environment silently discards. + linux_categories = self.escape_linux_desktop_categories( + self.options.linux_categories + or self.get_pyproject("tool.flet.linux.categories") + or ["Utility"] + ) + except ValueError as e: + self.cleanup(1, f"Invalid tool.flet.linux.categories: {e}") + # Resolved before the context, because the PWA background falls + # through to it. + splash = self._resolve_splash() + org_name = ( + self.options.org_name or self.get_platform_setting("org") or "com.flet" ) self.template_data = { @@ -1651,11 +1320,10 @@ def _xml_attr_value(v): ), "copyright": self.options.copyright or self.get_pyproject("tool.flet.copyright"), - "ios_export_method": ios_export_method, - "ios_provisioning_profile": ios_provisioning_profile, - "ios_signing_certificate": ios_signing_certificate, - "ios_export_options": ios_export_options, - "ios_team_id": ios_team_id, + "ios_export_method": ios_signing["export_method"], + "ios_provisioning_profile": ios_signing["provisioning_profile"], + "ios_signing_certificate": ios_signing["signing_certificate"], + "ios_team_id": ios_signing["team_id"], # `_` makes `_options` and `_pyproject` private cookiecutter # variables, which reach the template unchanged. Cookiecutter # renders other values as Jinja and turns them into strings. @@ -1669,9 +1337,10 @@ def _xml_attr_value(v): if self.package_platform == "Android" else [] ), - "info_plist": info_plist, - "macos_entitlements": macos_entitlements, + "info_plist": self.get_info_plist(impeller), + "macos_entitlements": self.get_macos_entitlements(), "ios_entitlements": self.get_ios_entitlements(), + "ios_export_options": ios_signing["export_options"], "android_permissions": android_permissions, "android_features": android_features, "android_meta_data": android_meta_data, @@ -2913,17 +2582,11 @@ def package_python_app(self): # (CLI / pyproject) is merged on top. Consumed by the serious_python_android # Gradle split during `flutter build`, so the env var is set on build_env # (see _run_flutter_command), not on the package step. - self.android_extract_packages: list[str] = [] - if self.package_platform == "Android": - user_extract_packages = ( - self.options.android_extract_packages - or self.get_platform_setting("extract_packages", []) - ) - self.android_extract_packages = list( - dict.fromkeys( - ANDROID_DEFAULT_EXTRACT_PACKAGES + user_extract_packages - ) - ) + self.android_extract_packages: list[str] = ( + self.get_android_extract_packages() + if self.package_platform == "Android" + else [] + ) if self.get_bool_setting(self.options.compile_app, "compile.app", True): package_args.append("--compile-app") @@ -3166,69 +2829,252 @@ def get_deep_linking(self) -> dict: ) return values - def get_ios_entitlements(self) -> dict: + def get_info_plist(self, impeller: bool) -> dict: """ - Resolve the iOS app's entitlements. + Resolve the entries added to the iOS or macOS app's `Info.plist`. - Entries of `--ios-entitlements` override those of `[tool.flet.ios.entitlement]`. - Values are written to `Runner.entitlements` with their plist types. + Entries of `--info-plist` override those of `[tool.flet.ios.info]` or + `[tool.flet.macos.info]`, which override those of the permission bundles. + + Args: + impeller: Whether the app uses Impeller. When it doesn't, macOS apps + get `FLTEnableImpeller = false`; iOS has no such switch. + + Returns: + The entries, with their plist value types. """ assert self.get_pyproject assert self.options - ios_entitlements = dict(self.get_pyproject("tool.flet.ios.entitlement") or {}) + info_plist = ( + {"FLTEnableImpeller": False} + if self.package_platform == "Darwin" and not impeller + else {} + ) - # parse --ios-entitlements - for p in self.options.ios_entitlements: + # merge values from "--permissions" arg: + for p in self.resolve_permissions(): + if p in CROSS_PLATFORM_PERMISSIONS: + permission_config = CROSS_PLATFORM_PERMISSIONS[p] + info_plist.update( + permission_config.get(f"{self.config_platform}_info_plist", {}) + or permission_config.get("info_plist", {}) + ) + + info_plist = merge_dict( + info_plist, + ( + self.get_pyproject("tool.flet.macos.info") + if self.package_platform == "Darwin" + else self.get_pyproject("tool.flet.ios.info") + ) + or {}, + ) + + # parse --info-plist + for p in self.options.info_plist: i = p.find("=") if i > -1: - ios_entitlements[p[:i]] = parse_cli_plist_value(p[i + 1 :]) + k = p[:i] + v = p[i + 1 :] + info_plist[k] = parse_cli_plist_value(v) else: - self.cleanup(1, f"Invalid iOS entitlement option: {p}") + self.cleanup(1, f"Invalid Info.plist option: {p}") - for key, value in ios_entitlements.items(): + for key, value in info_plist.items(): if not is_supported_plist_value(value): self.cleanup( 1, - "Unsupported iOS entitlement value type for " + "Unsupported Info.plist value type for " f"{key}: {type(value).__name__}. Supported types are " "string, boolean, integer, float, dictionary, and arrays " "containing those values.", ) - return ios_entitlements + return info_plist - def get_android_compile_options(self) -> dict[str, str]: - """Merge Android compiler expressions over installed extensions' and - Flet's Java 17 defaults. + def get_macos_entitlements(self) -> dict: + """ + Resolve the macOS app's entitlements. - Keys are Gradle property identifiers; values are Kotlin expressions - rendered verbatim inside `android.compileOptions`. + Entries of `--macos-entitlements` override those of + `[tool.flet.macos.entitlement]`, which override those of the permission + bundles and Flet's defaults. + + Returns: + The entitlements, with their plist value types. """ assert self.get_pyproject - setting = "tool.flet.android.compile_options" - try: - configured = merge_compile_options( - self.get_pyproject(setting), self.extensions - ) - except ValueError as e: - self.cleanup(1, str(e)) - if configured is None: - configured = {} - if not isinstance(configured, dict): - self.cleanup( - 1, f"Invalid {setting}: expected a table of Kotlin expressions." - ) + assert self.options + macos_entitlements = { + "com.apple.security.app-sandbox": False, + "com.apple.security.cs.allow-jit": True, + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.network.client": True, + "com.apple.security.network.server": True, + "com.apple.security.files.user-selected.read-write": True, + } - resolved = DEFAULT_ANDROID_COMPILE_OPTIONS.copy() - for name, expression in configured.items(): - if not isinstance(name, str) or not re.fullmatch( - r"[A-Za-z_][A-Za-z0-9_]*", name - ): - self.cleanup( - 1, - f"Invalid {setting} key {name!r}: expected a property identifier.", + # merge values from "--permissions" arg: + for p in self.resolve_permissions(): + if p in CROSS_PLATFORM_PERMISSIONS: + macos_entitlements.update( + CROSS_PLATFORM_PERMISSIONS[p]["macos_entitlements"] ) - if not isinstance(expression, str) or not expression.strip(): - self.cleanup( + + macos_entitlements = merge_dict( + macos_entitlements, + self.get_pyproject("tool.flet.macos.entitlement") or {}, + ) + + # parse --macos-entitlements + for p in self.options.macos_entitlements: + i = p.find("=") + if i > -1: + macos_entitlements[p[:i]] = parse_cli_plist_value(p[i + 1 :]) + else: + self.cleanup(1, f"Invalid macOS entitlement option: {p}") + + for key, value in macos_entitlements.items(): + if not is_supported_plist_value(value): + self.cleanup( + 1, + "Unsupported macOS entitlement value type for " + f"{key}: {type(value).__name__}. Supported types are " + "string, boolean, integer, float, dictionary, and arrays " + "containing those values.", + ) + return macos_entitlements + + def get_ios_signing(self) -> dict: + """ + Resolve how the iOS app is signed and exported. + + The export method comes from `--ios-export-method`, else + `[tool.flet.ios].export_method`, else `debugging`. Each other setting comes + from its CLI option if it has one, else the table of that export method + under `[tool.flet.ios.export_methods]`, else `[tool.flet.ios]`. + + Returns: + A table with `export_method`, `provisioning_profile`, + `signing_certificate`, `team_id` and `export_options`. + """ + assert self.get_pyproject + assert self.options + export_method = ( + self.options.ios_export_method + or self.get_pyproject("tool.flet.ios.export_method") + or "debugging" + ) + + export_method_opts = ( + self.get_pyproject("tool.flet.ios.export_methods").get(export_method) + if self.get_pyproject("tool.flet.ios.export_methods") + else {} + ) or {} + + export_options = ( + export_method_opts.get("export_options") + or self.get_pyproject("tool.flet.ios.export_options") + or {} + ) + if not isinstance(export_options, dict): + self.cleanup( + 1, + "Invalid iOS export options: expected a table, " + f"got {export_options!r}.", + ) + for key, value in export_options.items(): + if not is_supported_plist_value(value): + self.cleanup( + 1, + "Unsupported iOS export option value type for " + f"{key}: {type(value).__name__}. Supported types are " + "string, boolean, integer, float, dictionary, and arrays " + "containing those values.", + ) + + return { + "export_method": export_method, + "provisioning_profile": ( + self.options.ios_provisioning_profile + or export_method_opts.get("provisioning_profile") + or self.get_pyproject("tool.flet.ios.provisioning_profile") + ), + "signing_certificate": ( + self.options.ios_signing_certificate + or export_method_opts.get("signing_certificate") + or self.get_pyproject("tool.flet.ios.signing_certificate") + ), + "team_id": ( + self.options.ios_team_id + or export_method_opts.get("team_id") + or self.get_pyproject("tool.flet.ios.team_id") + ), + "export_options": export_options, + } + + def get_ios_entitlements(self) -> dict: + """ + Resolve the iOS app's entitlements. + + Entries of `--ios-entitlements` override those of `[tool.flet.ios.entitlement]`. + Values are written to `Runner.entitlements` with their plist types. + """ + assert self.get_pyproject + assert self.options + ios_entitlements = dict(self.get_pyproject("tool.flet.ios.entitlement") or {}) + + # parse --ios-entitlements + for p in self.options.ios_entitlements: + i = p.find("=") + if i > -1: + ios_entitlements[p[:i]] = parse_cli_plist_value(p[i + 1 :]) + else: + self.cleanup(1, f"Invalid iOS entitlement option: {p}") + + for key, value in ios_entitlements.items(): + if not is_supported_plist_value(value): + self.cleanup( + 1, + "Unsupported iOS entitlement value type for " + f"{key}: {type(value).__name__}. Supported types are " + "string, boolean, integer, float, dictionary, and arrays " + "containing those values.", + ) + return ios_entitlements + + def get_android_compile_options(self) -> dict[str, str]: + """Merge Android compiler expressions over installed extensions' and + Flet's Java 17 defaults. + + Keys are Gradle property identifiers; values are Kotlin expressions + rendered verbatim inside `android.compileOptions`. + """ + assert self.get_pyproject + setting = "tool.flet.android.compile_options" + try: + configured = merge_compile_options( + self.get_pyproject(setting), self.extensions + ) + except ValueError as e: + self.cleanup(1, str(e)) + if configured is None: + configured = {} + if not isinstance(configured, dict): + self.cleanup( + 1, f"Invalid {setting}: expected a table of Kotlin expressions." + ) + + resolved = DEFAULT_ANDROID_COMPILE_OPTIONS.copy() + for name, expression in configured.items(): + if not isinstance(name, str) or not re.fullmatch( + r"[A-Za-z_][A-Za-z0-9_]*", name + ): + self.cleanup( + 1, + f"Invalid {setting} key {name!r}: expected a property identifier.", + ) + if not isinstance(expression, str) or not expression.strip(): + self.cleanup( 1, f"Invalid {setting}.{name}: expected a nonempty Kotlin expression " "string.", @@ -3264,6 +3110,371 @@ def get_android_application_attributes(self, product_name: str) -> dict: except ValueError as exc: self.cleanup(1, str(exc)) + def get_android_permissions(self) -> dict: + """ + Resolve the app's `` entries. + + `INTERNET` is on by default. The cross-platform permission bundles, + the project settings and then the CLI options are applied on top; a + value of `False` removes the permission, even one a plugin adds. + + Returns: + Permission names mapped to `True`, `False`, or a table of extra + attributes. + """ + + assert self.get_pyproject + android_permissions = {"android.permission.INTERNET": True} + for p in self.resolve_permissions(): + if p in CROSS_PLATFORM_PERMISSIONS: + android_permissions.update( + CROSS_PLATFORM_PERMISSIONS[p]["android_permissions"] + ) + android_permissions = merge_dict( + android_permissions, + self.get_pyproject("tool.flet.android.permission") or {}, + ) + + # parse --android-permissions + for p in self.options.android_permissions: + i = p.find("=") + if i > -1: + try: + android_permissions[p[:i]] = parse_cli_bool_value(p[i + 1 :]) + except ValueError: + self.cleanup( + 1, + f"Invalid Android permission option value for {p[:i]}: " + f"{p[i + 1 :]}. Expected true or false.", + ) + else: + self.cleanup(1, f"Invalid Android permission option: {p}") + + for key, value in android_permissions.items(): + if isinstance(value, bool): + continue + if isinstance(value, dict): + for ak, av in value.items(): + if not isinstance(av, (str, bool, int, float)): + self.cleanup( + 1, + f"Invalid Android permission attribute value for " + f"{key}.{ak}: {type(av).__name__}. " + "Expected string, boolean, or number.", + ) + continue + self.cleanup( + 1, + f"Invalid Android permission value for {key}: " + f"{type(value).__name__}. Expected boolean or inline table.", + ) + return android_permissions + + def get_android_features(self) -> dict: + """ + Resolve the app's `` entries. + + Leanback and touchscreen are declared as not required by default, so + the app installs on TVs and devices without a touchscreen. The + cross-platform permission bundles, the project settings and then the + CLI options are applied on top. + + Returns: + Feature names mapped to whether the feature is required. + """ + + assert self.get_pyproject + android_features = { + "android.software.leanback": False, + "android.hardware.touchscreen": False, + } + for p in self.resolve_permissions(): + if p in CROSS_PLATFORM_PERMISSIONS: + android_features.update( + CROSS_PLATFORM_PERMISSIONS[p]["android_features"] + ) + android_features = merge_dict( + android_features, + self.get_pyproject("tool.flet.android.feature") or {}, + ) + + # parse --android-features + for p in self.options.android_features: + i = p.find("=") + if i > -1: + try: + android_features[p[:i]] = parse_cli_bool_value(p[i + 1 :]) + except ValueError: + self.cleanup( + 1, + f"Invalid Android feature option value for {p[:i]}: " + f"{p[i + 1 :]}. Expected true or false.", + ) + else: + self.cleanup(1, f"Invalid Android feature option: {p}") + return android_features + + def get_android_meta_data(self, impeller: bool) -> dict: + """ + Resolve the `` entries of the app's ``. + + Without Impeller, `io.flutter.embedding.android.EnableImpeller` is set + to `"false"`; the project settings and then the CLI options are + applied on top and may override it. + + Args: + impeller: Whether the app is built with the Impeller renderer. + + Returns: + Meta-data names mapped to their values. + """ + + assert self.get_pyproject + android_meta_data = ( + {"io.flutter.embedding.android.EnableImpeller": "false"} + if not impeller + else {} + ) + android_meta_data = merge_dict( + android_meta_data, + self.get_pyproject("tool.flet.android.meta_data") or {}, + ) + + # parse --android-meta-data + for p in self.options.android_meta_data: + i = p.find("=") + if i > -1: + android_meta_data[p[:i]] = p[i + 1 :] + else: + self.cleanup(1, f"Invalid Android meta-data option: {p}") + return android_meta_data + + def get_android_providers(self) -> dict: + """ + Resolve the app's own `` entries from project settings. + + A provider set to `False` or an empty table is skipped. Booleans are + written as lowercase `true` or `false`. + + Returns: + Provider class names mapped to their attributes, with an optional + `meta_data` table. + """ + + assert self.get_pyproject + android_providers = merge_dict( + {}, self.get_pyproject("tool.flet.android.provider") or {} + ) + + def _xml_attr_value(v): + # Android XML expects lowercase booleans. + if isinstance(v, bool): + return "true" if v else "false" + return v + + normalized_providers = {} + for key, value in android_providers.items(): + if value is False or value == {}: + continue + if value is True: + self.cleanup( + 1, + f"Invalid Android provider value for {key}: 'true' is not " + "supported. Use an inline table of attributes, or 'false' " + "to skip.", + ) + if not isinstance(value, dict): + self.cleanup( + 1, + f"Invalid Android provider value for {key}: " + f"{type(value).__name__}. Expected boolean or inline table.", + ) + normalized = {} + for ak, av in value.items(): + if ak == "name": + self.cleanup( + 1, + f"Invalid Android provider attribute for {key}: " + "'name' is reserved and is taken from the table key.", + ) + if ak == "meta_data": + if not isinstance(av, dict): + self.cleanup( + 1, + f"Invalid Android provider meta_data for {key}: " + f"{type(av).__name__}. Expected inline table.", + ) + normalized_meta = {} + for mk, mv in av.items(): + if isinstance(mv, (str, bool, int, float)): + normalized_meta[mk] = _xml_attr_value(mv) + continue + if isinstance(mv, dict): + normalized_attrs = {} + for attr_key, attr_value in mv.items(): + if not isinstance(attr_value, (str, bool, int, float)): + self.cleanup( + 1, + f"Invalid Android provider meta-data " + f"attribute value for " + f"{key}.meta_data.{mk}.{attr_key}: " + f"{type(attr_value).__name__}. " + "Expected string, boolean, or number.", + ) + normalized_attrs[attr_key] = _xml_attr_value(attr_value) + normalized_meta[mk] = normalized_attrs + continue + self.cleanup( + 1, + f"Invalid Android provider meta-data value for " + f"{key}.meta_data.{mk}: {type(mv).__name__}. " + "Expected string, boolean, number, or inline table.", + ) + normalized["meta_data"] = normalized_meta + continue + if not isinstance(av, (str, bool, int, float)): + self.cleanup( + 1, + f"Invalid Android provider attribute value for " + f"{key}.{ak}: {type(av).__name__}. " + "Expected string, boolean, or number.", + ) + normalized[ak] = _xml_attr_value(av) + normalized_providers[key] = normalized + return normalized_providers + + def get_android_gradle_properties(self) -> dict: + """ + Resolve the generated Android project's `gradle.properties`. + + The defaults reproduce the file the template used to hardcode; project + settings override any entry or add new ones. The memory settings in + particular are too large for some CI runners (a standard GitHub-hosted + runner has about 7 GB of RAM, less than `-Xmx8G` alone). + `kotlin.incremental` is off because Kotlin's incremental compiler + stores plugin sources relative to the Android project and fails the + build when they sit on another Windows drive, e.g. the pub cache on + `C:` and the app on `D:`. + + Returns: + Property names mapped to their values. + """ + + assert self.get_pyproject + return merge_dict( + { + "org.gradle.jvmargs": ( + "-Xmx8G -XX:MaxMetaspaceSize=4G " + "-XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError" + ), + "android.useAndroidX": "true", + "kotlin.incremental": "false", + }, + self.get_pyproject("tool.flet.android.gradle_properties") or {}, + ) + + def get_android_proguard_rules(self) -> list[str]: + """ + Resolve the generated Android project's ProGuard/R8 rules. + + R8 renames classes in release builds while pyjnius resolves them by + name, so an app that reaches into a bundled AAR needs keep rules. The + project's rules are appended to the defaults, since R8 has no directive + that undoes a keep; `proguard_default_rules = false` drops the + defaults, the only way to shed `-keepnames class * { *; }`. Keeping + the two settings separate stops users from copying today's defaults + into `pyproject.toml` and silently keeping them after they change. + + Returns: + The rules, one per line of `proguard-rules.pro`. + """ + + assert self.get_pyproject + keep_defaults = ( + self.get_pyproject("tool.flet.android.proguard_default_rules") is not False + ) + defaults = ( + [ + "-keep class com.flet.serious_python_android.** { *; }", + "-keepnames class * { *; }", + ] + if keep_defaults + else [] + ) + return defaults + [ + str(rule) + for rule in (self.get_pyproject("tool.flet.android.proguard_rules") or []) + ] + + def get_android_signing_env(self) -> dict: + """ + Resolve the environment variables that sign the Android build. + + The key store comes from the CLI option, the project settings or the + `FLET_ANDROID_SIGNING_KEY_STORE` environment variable. The passwords + come from the CLI options or their environment variables only, never + from `pyproject.toml`; when just one of them is given, it is used for + both. The key alias, `upload` by default, is set only with a key store. + + Returns: + The `FLET_ANDROID_SIGNING_*` variables to pass to the build. + """ + + assert self.get_pyproject + env = {} + key_store = ( + self.options.android_signing_key_store + or self.get_pyproject("tool.flet.android.signing.key_store") + or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE") + ) + if key_store: + env["FLET_ANDROID_SIGNING_KEY_STORE"] = key_store + + key_store_password = ( + self.options.android_signing_key_store_password + or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") + ) + key_password = self.options.android_signing_key_password or os.getenv( + "FLET_ANDROID_SIGNING_KEY_PASSWORD" + ) + if key_store_password or key_password: + env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( + key_store_password if key_store_password else key_password + ) + env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( + key_password if key_password else key_store_password + ) + + if key_store: + env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = ( + self.options.android_signing_key_alias + or self.get_pyproject("tool.flet.android.signing.key_alias") + or os.getenv("FLET_ANDROID_SIGNING_KEY_ALIAS") + or "upload" + ) + return env + + def get_android_extract_packages(self) -> list[str]: + """ + Resolve the Python packages shipped extracted to disk on Android + instead of in the zip Python imports from, for packages that read + their data through a real file path. + + Flet's defaults come first, followed by the CLI option or else the + project settings; duplicates are dropped. + + Returns: + Package names, in order. + """ + + user_extract_packages = ( + self.options.android_extract_packages + or self.get_platform_setting("extract_packages", []) + ) + return list( + dict.fromkeys(ANDROID_DEFAULT_EXTRACT_PACKAGES + user_extract_packages) + ) + def get_android_activity(self) -> dict: """Resolve what the app adds to its main activity, such as intent filters, from project settings.""" @@ -3537,37 +3748,7 @@ def _run_flutter_command(self): if self.package_platform == "Emscripten" and self.build_wasm(): build_args.append("--wasm") - android_signing_key_store = ( - self.options.android_signing_key_store - or self.get_pyproject("tool.flet.android.signing.key_store") - or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE") - ) - if android_signing_key_store: - build_env["FLET_ANDROID_SIGNING_KEY_STORE"] = android_signing_key_store - - key_store_password = ( - self.options.android_signing_key_store_password - or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") - ) - key_password = self.options.android_signing_key_password or os.getenv( - "FLET_ANDROID_SIGNING_KEY_PASSWORD" - ) - if key_store_password or key_password: - build_env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( - key_store_password if key_store_password else key_password - ) - build_env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( - key_password if key_password else key_store_password - ) - - if android_signing_key_store: - android_signing_key_alias = ( - self.options.android_signing_key_alias - or self.get_pyproject("tool.flet.android.signing.key_alias") - or os.getenv("FLET_ANDROID_SIGNING_KEY_ALIAS") - or "upload" - ) - build_env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = android_signing_key_alias + build_env.update(self.get_android_signing_env()) flutter_build_args = ( self.options.flutter_build_args diff --git a/sdk/python/packages/flet-cli/tests/test_build_android.py b/sdk/python/packages/flet-cli/tests/test_build_android.py index 034e406497..9418a3f9d9 100644 --- a/sdk/python/packages/flet-cli/tests/test_build_android.py +++ b/sdk/python/packages/flet-cli/tests/test_build_android.py @@ -1,3 +1,4 @@ +import argparse import contextlib import copy import xml.etree.ElementTree as ET @@ -7,6 +8,7 @@ from jinja2 import Environment, StrictUndefined from flet_cli.commands.build_base import BaseBuildCommand +from flet_cli.utils.android import excluded_android_abis, flutter_target_platforms from flet_cli.utils.project_dependencies import get_project_dependencies ANDROID_NS = "http://schemas.android.com/apk/res/android" @@ -31,17 +33,38 @@ def cleanup(code, message): return cmd -def _render_gradle(cmd): +def _cli_command(settings=None, argv=(), package_platform="Android"): + """Create a build command with the real `flet build` options parsed from + `argv`, whose configuration errors raise ValueError.""" + parser = argparse.ArgumentParser(add_help=False) + cmd = BaseBuildCommand(parser) + cmd.options = parser.parse_args(list(argv)) + values = settings or {} + cmd.get_pyproject = lambda key=None: values.get(key) + cmd.extensions = [] + cmd.package_platform = package_platform + cmd.config_platform = package_platform.lower() + + def cleanup(code, message): + """Capture a failed build setting without exiting the test process.""" + assert code == 1 + raise ValueError(message) + + cmd.cleanup = cleanup + return cmd + + +def _render_gradle(cmd, pyproject=None, split_per_abi=False, **options): """Render the Android Gradle template using resolved command settings.""" env = Environment(undefined=StrictUndefined) - env.globals["get_pyproject"] = lambda key: None + env.globals["get_pyproject"] = (pyproject or {}).get return env.from_string( (ANDROID_APP_TEMPLATE / "build.gradle.kts").read_text() ).render( cookiecutter={ "org_name_2": "com.example", "package_name": "example", - "split_per_abi": False, + "split_per_abi": split_per_abi, "_options": { "android_compile_options": cmd.get_android_compile_options(), "android_gradle_dependencies": cmd.get_android_gradle_dependencies(), @@ -49,14 +72,15 @@ def _render_gradle(cmd): "android_excluded_abis": [], "android_signing": False, "target_arch": [], + **options, }, } ) -def _render_manifest(cmd): +def _render_manifest(cmd, **options): """Parse a rendered manifest containing queries, receivers, and existing app - entries.""" + entries; `options` replace the other template options.""" path = ANDROID_APP_TEMPLATE / "src/main/AndroidManifest.xml" rendered = ( Environment(undefined=StrictUndefined) @@ -80,6 +104,7 @@ def _render_manifest(cmd): "com.example.Provider": {"exported": "false"} }, "deep_linking": {"scheme": None, "host": None}, + **options, }, } ) @@ -87,6 +112,11 @@ def _render_manifest(cmd): return ET.fromstring(rendered) +def _attr(element, key): + """Return an Android XML attribute of an element.""" + return element.get(f"{{{ANDROID_NS}}}{key}") + + class TestCompileOptions: """Cover compiler defaults, per-property overrides, and Kotlin expressions.""" @@ -634,6 +664,572 @@ def attr(element, key): ] +class TestPermissions: + """Cover `` defaults, bundles, settings, CLI and rendering.""" + + def test_internet_is_the_default(self): + """Without settings, only `INTERNET` is requested.""" + assert _cli_command().get_android_permissions() == { + "android.permission.INTERNET": True + } + + def test_bundles_then_settings_then_cli(self): + """Bundles add permissions, settings override them and the CLI wins.""" + cmd = _cli_command( + { + "tool.flet.android.permission": { + "android.permission.CAMERA": False, + "android.permission.RECORD_AUDIO": True, + "android.permission.BLUETOOTH_SCAN": { + "usesPermissionFlags": "neverForLocation" + }, + } + }, + [ + "--permissions", + "camera", + "--android-permissions", + "android.permission.RECORD_AUDIO=false", + "android.permission.VIBRATE=TRUE", + ], + ) + assert cmd.get_android_permissions() == { + "android.permission.INTERNET": True, + "android.permission.CAMERA": False, + "android.permission.RECORD_AUDIO": False, + "android.permission.BLUETOOTH_SCAN": { + "usesPermissionFlags": "neverForLocation" + }, + "android.permission.VIBRATE": True, + } + + def test_disabled_permissions_render_a_remove_entry(self): + """`False` writes `tools:node="remove"`; a table becomes attributes.""" + manifest = _render_manifest( + _command({}), + android_permissions={ + "android.permission.INTERNET": True, + "android.permission.READ_EXTERNAL_STORAGE": False, + "android.permission.ACCESS_FINE_LOCATION": {"maxSdkVersion": "30"}, + }, + ) + permissions = {_attr(p, "name"): p for p in manifest.findall("uses-permission")} + removed = permissions["android.permission.READ_EXTERNAL_STORAGE"] + assert removed.get("{http://schemas.android.com/tools}node") == "remove" + assert ( + _attr( + permissions["android.permission.ACCESS_FINE_LOCATION"], "maxSdkVersion" + ) + == "30" + ) + + @pytest.mark.parametrize( + "settings, argv, message", + [ + ({}, ["--android-permissions", "android.permission.CAMERA"], "option:"), + ( + {}, + ["--android-permissions", "android.permission.CAMERA=maybe"], + "Expected true or false", + ), + ( + {"tool.flet.android.permission": {"android.permission.CAMERA": "yes"}}, + [], + "Expected boolean or inline table", + ), + ( + { + "tool.flet.android.permission": { + "android.permission.CAMERA": {"maxSdkVersion": []} + } + }, + [], + "Expected string, boolean, or number", + ), + ], + ) + def test_invalid_permissions_fail(self, settings, argv, message): + """Malformed CLI options and setting values stop the build.""" + with pytest.raises(ValueError, match=message): + _cli_command(settings, argv).get_android_permissions() + + +class TestFeatures: + """Cover `` defaults, bundles, settings, CLI and rendering.""" + + def test_leanback_and_touchscreen_are_optional_by_default(self): + """Without settings, TV and touchscreen support aren't required.""" + assert _cli_command().get_android_features() == { + "android.software.leanback": False, + "android.hardware.touchscreen": False, + } + + def test_bundles_then_settings_then_cli(self): + """Bundles add features, settings override them and the CLI wins.""" + cmd = _cli_command( + { + "tool.flet.android.feature": { + "android.hardware.camera": True, + "android.hardware.touchscreen": True, + } + }, + [ + "--permissions", + "camera", + "--android-features", + "android.hardware.touchscreen=false", + ], + ) + features = cmd.get_android_features() + assert features["android.hardware.camera"] is True + assert features["android.hardware.camera.front"] is False + assert features["android.hardware.touchscreen"] is False + + def test_features_render_required(self): + """Each feature is written with its `required` value.""" + manifest = _render_manifest( + _command({}), + android_features={"android.hardware.camera": True, "android.x": False}, + ) + assert { + _attr(f, "name"): _attr(f, "required") + for f in manifest.findall("uses-feature") + } == {"android.hardware.camera": "true", "android.x": "false"} + + @pytest.mark.parametrize( + "argv, message", + [ + (["--android-features", "android.hardware.camera"], "option:"), + (["--android-features", "android.hardware.camera=1x"], "true or false"), + ], + ) + def test_invalid_cli_values_fail(self, argv, message): + """Malformed `--android-features` entries stop the build.""" + with pytest.raises(ValueError, match=message): + _cli_command({}, argv).get_android_features() + + +class TestMetaData: + """Cover `` meta-data defaults, settings, CLI and rendering.""" + + @pytest.mark.parametrize( + "impeller, expected", + [ + (True, {}), + (False, {"io.flutter.embedding.android.EnableImpeller": "false"}), + ], + ) + def test_impeller_default(self, impeller, expected): + """Building without Impeller turns it off through meta-data.""" + assert _cli_command().get_android_meta_data(impeller) == expected + + def test_settings_then_cli(self): + """Settings override the Impeller default and the CLI wins.""" + cmd = _cli_command( + { + "tool.flet.android.meta_data": { + "io.flutter.embedding.android.EnableImpeller": "true", + "com.example.timeout": "30", + } + }, + ["--android-meta-data", "com.example.timeout=60", "com.example.url=a=b"], + ) + assert cmd.get_android_meta_data(False) == { + "io.flutter.embedding.android.EnableImpeller": "true", + "com.example.timeout": "60", + "com.example.url": "a=b", + } + + def test_meta_data_renders_under_application(self): + """Each entry is an `` ``, next to the template's + own `flutterEmbedding` entry.""" + manifest = _render_manifest( + _command({}), android_meta_data={"com.example.timeout": "30"} + ) + assert { + _attr(m, "name"): _attr(m, "value") + for m in manifest.findall("application/meta-data") + } == {"com.example.timeout": "30", "flutterEmbedding": "2"} + + def test_invalid_cli_value_fails(self): + """An `--android-meta-data` entry without `=` stops the build.""" + with pytest.raises(ValueError, match="meta-data option"): + _cli_command({}, ["--android-meta-data", "flag"]).get_android_meta_data( + True + ) + + +class TestProviders: + """Cover app `` declarations, validation and rendering.""" + + @staticmethod + def _resolve(configured): + """Resolve provider declarations through the build command.""" + return _cli_command( + {"tool.flet.android.provider": configured} + ).get_android_providers() + + @pytest.mark.parametrize("configured", [None, {}, {"com.example.P": False}]) + def test_absent_or_disabled_providers(self, configured): + """No table, an empty one, or `false` declares nothing.""" + assert self._resolve(configured) == {} + + def test_attributes_and_meta_data_are_normalized(self): + """Booleans become lowercase strings in attributes and meta-data.""" + assert self._resolve( + { + "com.example.P": { + "authorities": "${applicationId}.p", + "exported": False, + "meta_data": { + "com.example.flag": True, + "com.example.paths": {"resource": "@xml/paths"}, + }, + }, + "com.example.Skipped": {}, + } + ) == { + "com.example.P": { + "authorities": "${applicationId}.p", + "exported": "false", + "meta_data": { + "com.example.flag": "true", + "com.example.paths": {"resource": "@xml/paths"}, + }, + } + } + + def test_providers_render_after_flets_file_provider(self): + """The app's providers follow Flet's `FileProvider` with their meta-data.""" + cmd = _cli_command( + { + "tool.flet.android.provider": { + "com.example.P": { + "authorities": "${applicationId}.p", + "meta_data": {"com.example.paths": {"resource": "@xml/p"}}, + } + } + } + ) + manifest = _render_manifest(cmd, android_providers=cmd.get_android_providers()) + providers = manifest.findall("application/provider") + assert [_attr(p, "name") for p in providers] == [ + "androidx.core.content.FileProvider", + "com.example.P", + ] + assert _attr(providers[1].find("meta-data"), "resource") == "@xml/p" + + @pytest.mark.parametrize( + "configured, message", + [ + ({"com.example.P": True}, "'true' is not supported"), + ({"com.example.P": "x"}, "Expected boolean or inline table"), + ({"com.example.P": {"name": "other"}}, "'name' is reserved"), + ({"com.example.P": {"exported": []}}, "Expected string, boolean"), + ({"com.example.P": {"meta_data": []}}, "Expected inline table"), + ({"com.example.P": {"meta_data": {"k": []}}}, "or inline table"), + ({"com.example.P": {"meta_data": {"k": {"value": []}}}}, "k.value"), + ], + ) + def test_invalid_providers_fail(self, configured, message): + """Malformed provider declarations stop the build with a clear message.""" + with pytest.raises(ValueError, match=message): + self._resolve(configured) + + +class TestGradleProperties: + """Cover `gradle.properties` defaults, overrides and rendering.""" + + TEMPLATE = ANDROID_APP_TEMPLATE.parent / "gradle.properties" + + def test_defaults(self): + """Without settings, the properties the template used to hardcode apply.""" + properties = _cli_command().get_android_gradle_properties() + assert properties["android.useAndroidX"] == "true" + assert properties["kotlin.incremental"] == "false" + assert properties["org.gradle.jvmargs"].startswith("-Xmx8G ") + + def test_settings_override_and_add(self): + """Settings replace a default and add new properties, which render as-is.""" + cmd = _cli_command( + { + "tool.flet.android.gradle_properties": { + "org.gradle.jvmargs": "-Xmx4G", + "org.gradle.caching": "true", + } + } + ) + properties = cmd.get_android_gradle_properties() + rendered = ( + Environment(undefined=StrictUndefined) + .from_string(self.TEMPLATE.read_text()) + .render( + cookiecutter={"_options": {"android_gradle_properties": properties}} + ) + ) + lines = rendered.splitlines() + assert "org.gradle.jvmargs=-Xmx4G" in lines + assert "org.gradle.caching=true" in lines + assert "android.useAndroidX=true" in lines + + +class TestProguardRules: + """Cover ProGuard/R8 default rules, added rules and rendering.""" + + TEMPLATE = ANDROID_APP_TEMPLATE / "proguard-rules.pro" + DEFAULTS = [ + "-keep class com.flet.serious_python_android.** { *; }", + "-keepnames class * { *; }", + ] + + @staticmethod + def _rendered(rules): + """Render `proguard-rules.pro` and return its non-empty lines.""" + rendered = ( + Environment(undefined=StrictUndefined) + .from_string(TestProguardRules.TEMPLATE.read_text()) + .render(cookiecutter={"_options": {"android_proguard_rules": rules}}) + ) + return [line for line in rendered.splitlines() if line.strip()] + + @pytest.mark.parametrize( + "settings, expected", + [ + ({}, DEFAULTS), + ( + {"tool.flet.android.proguard_rules": ["-keep class org.x.** { *; }"]}, + [*DEFAULTS, "-keep class org.x.** { *; }"], + ), + ({"tool.flet.android.proguard_default_rules": False}, []), + ( + { + "tool.flet.android.proguard_default_rules": False, + "tool.flet.android.proguard_rules": ["-dontwarn org.x.**"], + }, + ["-dontwarn org.x.**"], + ), + ], + ) + def test_rules_append_to_the_defaults_unless_dropped(self, settings, expected): + """Rules add to the defaults, which only `proguard_default_rules` drops.""" + rules = _cli_command(settings).get_android_proguard_rules() + assert rules == expected + assert self._rendered(rules) == expected + + +class TestGradleTemplate: + """Cover SDK versions, ABIs and signing as rendered in `build.gradle.kts`.""" + + @pytest.mark.parametrize( + "pyproject, min_sdk, target_sdk", + [ + ({}, "flutter.minSdkVersion", "flutter.targetSdkVersion"), + ( + { + "tool.flet.android.min_sdk_version": 24, + "tool.flet.android.target_sdk_version": 35, + }, + "24", + "35", + ), + ], + ) + def test_sdk_versions(self, pyproject, min_sdk, target_sdk): + """SDK versions come from the settings, else Flutter's defaults.""" + rendered = _render_gradle(_command({}), pyproject) + assert f"val resolvedMinSdk = {min_sdk}" in rendered + assert f"val resolvedTargetSdk = {target_sdk}" in rendered + + def test_target_arch_filters_abis_unless_split(self): + """Target architectures become ABI filters; a split build has none.""" + cmd = _command({}) + filtered = _render_gradle(cmd, target_arch=["arm64-v8a"]) + assert 'abiFilters += listOf("arm64-v8a")' in filtered + assert "abiFilters" not in _render_gradle(cmd, split_per_abi=True) + + def test_excluded_abis_are_packaging_excludes(self): + """ABIs left out of the build are excluded from the packaged libraries.""" + rendered = _render_gradle( + _command({}), android_excluded_abis=["armeabi-v7a", "x86_64"] + ) + assert 'excludes += listOf("lib/armeabi-v7a/**", "lib/x86_64/**")' in rendered + + @pytest.mark.parametrize("signing", [False, True]) + def test_signing_config_only_with_a_key_store(self, signing): + """The release signing config is generated only when signing is set.""" + rendered = _render_gradle(_command({}), android_signing=signing) + assert ("FLET_ANDROID_SIGNING_KEY_STORE" in rendered) is signing + + +class TestAbis: + """Cover mapping and excluding Android ABIs.""" + + def test_flutter_target_platforms(self): + """ABI names map to `flutter build --target-platform` values in order.""" + assert flutter_target_platforms(["arm64-v8a", "x86_64"]) == [ + "android-arm64", + "android-x64", + ] + + def test_unknown_abi_fails(self): + """An ABI Flutter can't build is rejected with the supported list.""" + with pytest.raises(ValueError, match="Supported: armeabi-v7a"): + flutter_target_platforms(["mips"]) + + @pytest.mark.parametrize( + "archs, excluded", + [([], []), (["arm64-v8a"], ["armeabi-v7a", "x86_64"])], + ) + def test_excluded_android_abis(self, archs, excluded): + """Requested ABIs are kept; with none requested, nothing is excluded.""" + assert excluded_android_abis(archs) == excluded + + +class TestAdaptiveIconBackground: + """Cover the adaptive launcher icon's background color resource.""" + + def test_background_color_renders(self): + """The configured color becomes the `ic_launcher_background` resource.""" + rendered = ( + Environment(undefined=StrictUndefined) + .from_string( + ( + ANDROID_APP_TEMPLATE + / "src/main/res/values/ic_launcher_background.xml" + ).read_text() + ) + .render(cookiecutter={"adaptive_icon_background": "#FF6200EE"}) + ) + color = ET.fromstring(rendered).find("color") + assert (color.get("name"), color.text) == ( + "ic_launcher_background", + "#FF6200EE", + ) + + +class TestSigning: + """Cover the environment variables that sign the Android build.""" + + ENV = ( + "FLET_ANDROID_SIGNING_KEY_STORE", + "FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD", + "FLET_ANDROID_SIGNING_KEY_PASSWORD", + "FLET_ANDROID_SIGNING_KEY_ALIAS", + ) + + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch): + """Start each test without signing variables from the environment.""" + for name in self.ENV: + monkeypatch.delenv(name, raising=False) + + def test_nothing_without_a_key_store_or_passwords(self): + """Without settings, the build isn't signed.""" + assert _cli_command().get_android_signing_env() == {} + + def test_cli_then_settings_then_environment(self, monkeypatch): + """The CLI wins over settings, which win over environment variables.""" + monkeypatch.setenv("FLET_ANDROID_SIGNING_KEY_STORE", "env.jks") + monkeypatch.setenv("FLET_ANDROID_SIGNING_KEY_ALIAS", "env-alias") + settings = { + "tool.flet.android.signing.key_store": "project.jks", + "tool.flet.android.signing.key_alias": "project-alias", + } + assert _cli_command(settings).get_android_signing_env() == { + "FLET_ANDROID_SIGNING_KEY_STORE": "project.jks", + "FLET_ANDROID_SIGNING_KEY_ALIAS": "project-alias", + } + assert _cli_command( + settings, + [ + "--android-signing-key-store", + "cli.jks", + "--android-signing-key-alias", + "cli-alias", + ], + ).get_android_signing_env() == { + "FLET_ANDROID_SIGNING_KEY_STORE": "cli.jks", + "FLET_ANDROID_SIGNING_KEY_ALIAS": "cli-alias", + } + assert _cli_command().get_android_signing_env() == { + "FLET_ANDROID_SIGNING_KEY_STORE": "env.jks", + "FLET_ANDROID_SIGNING_KEY_ALIAS": "env-alias", + } + + def test_alias_defaults_to_upload(self): + """A key store without an alias uses `upload`.""" + env = _cli_command( + {"tool.flet.android.signing.key_store": "app.jks"} + ).get_android_signing_env() + assert env["FLET_ANDROID_SIGNING_KEY_ALIAS"] == "upload" + + @pytest.mark.parametrize( + "argv, expected", + [ + (["--android-signing-key-store-password", "s"], ("s", "s")), + (["--android-signing-key-password", "k"], ("k", "k")), + ( + [ + "--android-signing-key-store-password", + "s", + "--android-signing-key-password", + "k", + ], + ("s", "k"), + ), + ], + ) + def test_one_password_is_used_for_both(self, argv, expected): + """When only one password is given, it's used for the key store and key.""" + env = _cli_command({}, argv).get_android_signing_env() + assert ( + env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"], + env["FLET_ANDROID_SIGNING_KEY_PASSWORD"], + ) == expected + + def test_passwords_come_from_the_environment(self, monkeypatch): + """Passwords can come from environment variables instead of the CLI.""" + monkeypatch.setenv("FLET_ANDROID_SIGNING_KEY_PASSWORD", "env-key") + env = _cli_command().get_android_signing_env() + assert env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] == "env-key" + assert env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] == "env-key" + + def test_passwords_in_pyproject_are_ignored(self): + """Passwords aren't read from `pyproject.toml`, so they stay out of git.""" + env = _cli_command( + {"tool.flet.android.signing.key_store_password": "leaked"} + ).get_android_signing_env() + assert "FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD" not in env + + +class TestExtractPackages: + """Cover the Python packages shipped extracted to disk on Android.""" + + @pytest.mark.parametrize( + "settings, argv, expected", + [ + ({}, [], []), + ({"tool.flet.extract_packages": ["a", "b"]}, [], ["a", "b"]), + ( + { + "tool.flet.extract_packages": ["a"], + "tool.flet.android.extract_packages": ["b", "b"], + }, + [], + ["b"], + ), + ( + {"tool.flet.android.extract_packages": ["b"]}, + ["--android-extract-packages", "c", "d"], + ["c", "d"], + ), + ], + ) + def test_cli_then_platform_then_common_setting(self, settings, argv, expected): + """The CLI wins over the Android setting, which wins over the common one.""" + assert _cli_command(settings, argv).get_android_extract_packages() == expected + + class TestReceivers: """Cover receiver declarations, intent filters, and manifest preservation.""" diff --git a/sdk/python/packages/flet-cli/tests/test_build_ios.py b/sdk/python/packages/flet-cli/tests/test_build_ios.py index 78386de086..c83c11b0ee 100644 --- a/sdk/python/packages/flet-cli/tests/test_build_ios.py +++ b/sdk/python/packages/flet-cli/tests/test_build_ios.py @@ -3,14 +3,17 @@ import argparse import datetime import plistlib +import re +import xml.etree.ElementTree as ET from pathlib import Path from typing import Optional import pytest -from jinja2 import Environment +from jinja2 import Environment, StrictUndefined from flet_cli.commands import build as build_module from flet_cli.commands.build import Command +from flet_cli.commands.build_base import BaseBuildCommand from flet_cli.utils.ios_sign import ProvisioningProfile, find_provisioning_profile @@ -365,3 +368,465 @@ def test_project_signs_with_entitlements_only_when_declared( rendered.count("CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;") == expected ) + + +def _settings_command(settings=None, argv=(), package_platform="iOS"): + """Create a build command with the real `flet build` options parsed from + `argv`, whose configuration errors raise `Exit`.""" + parser = argparse.ArgumentParser(add_help=False) + cmd = BaseBuildCommand(parser) + cmd.options = parser.parse_args(list(argv)) + values = settings or {} + cmd.get_pyproject = lambda key=None: values.get(key) + cmd.package_platform = package_platform + cmd.config_platform = {"iOS": "ios", "Darwin": "macos"}[package_platform] + + def cleanup(code, message=None, **kwargs): + raise Exit(code, message) + + cmd.cleanup = cleanup + return cmd + + +def _render_template(name: str, options: Optional[dict] = None, **values) -> str: + """Render an iOS template file from a context processed by cookiecutter, + like a real build; `values` and `options` replace the build's settings.""" + from cookiecutter.prompt import prompt_for_config + + context = prompt_for_config( + { + "cookiecutter": { + "project_name": "example", + "product_name": "Example", + "artifact_name": "Example", + "bundle_id": "com.example.app", + "ios_export_method": "debugging", + "ios_provisioning_profile": None, + "ios_signing_certificate": None, + "ios_team_id": None, + **values, + "_options": { + "deep_linking": {"scheme": None, "host": None}, + "info_plist": {}, + "ios_entitlements": {}, + "ios_export_options": {}, + **(options or {}), + }, + } + }, + no_input=True, + ) + return ( + Environment(undefined=StrictUndefined, keep_trailing_newline=True) + .from_string((IOS_TEMPLATE_DIR / name).read_text()) + .render(cookiecutter=context) + ) + + +class TestInfoPlistSettings: + def test_no_settings_add_no_entries(self): + """Without settings, the build adds nothing to `Info.plist`.""" + assert _settings_command().get_info_plist(impeller=True) == {} + + def test_permission_bundle_adds_its_ios_entries(self): + """A permission bundle adds its iOS entries, not its macOS ones.""" + cmd = _settings_command(argv=["--permissions", "location"]) + assert set(cmd.get_info_plist(impeller=True)) == { + "NSLocationWhenInUseUsageDescription", + "NSLocationAlwaysAndWhenInUseUsageDescription", + } + + def test_biometric_bundle_from_the_cli(self): + """`--permissions biometric` adds the Face ID usage description.""" + cmd = _settings_command(argv=["--permissions", "biometric"]) + assert set(cmd.get_info_plist(impeller=True)) == {"NSFaceIDUsageDescription"} + + def test_unknown_permission_bundle_adds_nothing(self): + """A name that isn't a permission bundle adds no entries.""" + cmd = _settings_command({"tool.flet.permissions": ["unknown"]}) + assert cmd.get_info_plist(impeller=True) == {} + + def test_platform_permissions_replace_the_common_ones(self): + """`[tool.flet.ios].permissions` replaces `[tool.flet].permissions`.""" + cmd = _settings_command( + { + "tool.flet.ios.permissions": ["camera"], + "tool.flet.permissions": ["microphone"], + } + ) + assert set(cmd.get_info_plist(impeller=True)) == {"NSCameraUsageDescription"} + + def test_info_table_overrides_permission_bundles(self): + """`[tool.flet.ios.info]` overrides a bundle's entry and adds its own.""" + cmd = _settings_command( + { + "tool.flet.ios.permissions": ["camera"], + "tool.flet.ios.info": { + "NSCameraUsageDescription": "Scan receipts", + "UIStatusBarHidden": True, + }, + } + ) + assert cmd.get_info_plist(impeller=True) == { + "NSCameraUsageDescription": "Scan receipts", + "UIStatusBarHidden": True, + } + + def test_macos_table_is_ignored_on_ios(self): + """iOS builds don't read `[tool.flet.macos.info]`.""" + cmd = _settings_command({"tool.flet.macos.info": {"LSUIElement": True}}) + assert cmd.get_info_plist(impeller=True) == {} + + def test_cli_entries_override_the_table_and_keep_their_types(self): + """`--info-plist` entries override the table and get their value types.""" + cmd = _settings_command( + {"tool.flet.ios.info": {"Name": "table", "Kept": "table"}}, + argv=[ + "--info-plist", + "Name=cli", + "Enabled=true", + "Count=3", + "Ratio=0.5", + 'Names=["a", "b"]', + ], + ) + assert cmd.get_info_plist(impeller=True) == { + "Name": "cli", + "Kept": "table", + "Enabled": True, + "Count": 3, + "Ratio": 0.5, + "Names": ["a", "b"], + } + + def test_malformed_cli_entry_fails(self): + """An `--info-plist` entry without `=` is rejected.""" + cmd = _settings_command(argv=["--info-plist", "Name"]) + with pytest.raises(Exit, match="Invalid Info.plist option: Name"): + cmd.get_info_plist(impeller=True) + + def test_unsupported_value_type_fails(self): + """A value plists cannot hold is rejected with its key.""" + cmd = _settings_command( + {"tool.flet.ios.info": {"When": datetime.date(2030, 1, 1)}} + ) + with pytest.raises(Exit, match="Unsupported Info.plist value type for When"): + cmd.get_info_plist(impeller=True) + + @pytest.mark.parametrize( + ("package_platform", "expected"), + [("iOS", {}), ("Darwin", {"FLTEnableImpeller": False})], + ) + def test_impeller_off_only_adds_a_key_on_macos(self, package_platform, expected): + """Turning Impeller off adds a key on macOS; iOS has no such switch.""" + cmd = _settings_command(package_platform=package_platform) + assert cmd.get_info_plist(impeller=False) == expected + + +class TestInfoPlistTemplate: + def test_names_are_written_escaped(self): + """The product and project names are escaped in `Info.plist`.""" + plist = plistlib.loads( + _render_template( + "Runner/Info.plist", + product_name="Tom & Jerry", + project_name="tom", + ).encode() + ) + assert plist["CFBundleDisplayName"] == "Tom & Jerry" + assert plist["CFBundleName"] == "tom" + + def test_entries_keep_their_types_and_are_escaped(self): + """Every plist value type survives the build, and keys and text are + escaped and never rendered as Jinja.""" + entries = { + "Text": "Tom & Jerry <{{ x }}>", + "Tom&Jerry": "key", + "Enabled": True, + "Disabled": False, + "Count": 3, + "Ratio": 0.5, + "Table": {"Nested": ["a", 1, 0.5, True, {"Deep": "b"}]}, + } + plist = plistlib.loads( + _render_template( + "Runner/Info.plist", options={"info_plist": entries} + ).encode() + ) + assert {key: plist[key] for key in entries} == entries + + +def _podfile_macros(info_plist: dict) -> list: + """Return the permission macros the rendered `Podfile` enables.""" + rendered = _render_template("Podfile", options={"info_plist": info_plist}) + return re.findall(r"'(PERMISSION_\w+)=1'", rendered) + + +class TestPodfile: + def test_no_entries_enable_no_macros(self): + """Without usage descriptions, the app enables no permission.""" + assert _podfile_macros({}) == [] + + @pytest.mark.parametrize( + ("key", "macros"), + [ + ("NSCalendarsUsageDescription", ["PERMISSION_EVENTS"]), + ("NSCalendarWriteOnlyAccessUsageDescription", ["PERMISSION_EVENTS"]), + ( + "NSCalendarsFullAccessUsageDescription", + ["PERMISSION_EVENTS_FULL_ACCESS"], + ), + ("NSRemindersFullAccessUsageDescription", ["PERMISSION_REMINDERS"]), + ("NSContactsUsageDescription", ["PERMISSION_CONTACTS"]), + ("NSCameraUsageDescription", ["PERMISSION_CAMERA"]), + ("NSMicrophoneUsageDescription", ["PERMISSION_MICROPHONE"]), + ("NSSpeechRecognitionUsageDescription", ["PERMISSION_SPEECH_RECOGNIZER"]), + ("NSPhotoLibraryUsageDescription", ["PERMISSION_PHOTOS"]), + ("NSPhotoLibraryAddUsageDescription", ["PERMISSION_PHOTOS_ADD_ONLY"]), + ("NSLocationUsageDescription", ["PERMISSION_LOCATION"]), + ("NSLocationAlwaysAndWhenInUseUsageDescription", ["PERMISSION_LOCATION"]), + ( + "NSLocationWhenInUseUsageDescription", + ["PERMISSION_LOCATION", "PERMISSION_LOCATION_WHENINUSE"], + ), + ("NSAppleMusicUsageDescription", ["PERMISSION_MEDIA_LIBRARY"]), + ("NSMotionUsageDescription", ["PERMISSION_SENSORS"]), + ("NSBluetoothAlwaysUsageDescription", ["PERMISSION_BLUETOOTH"]), + ("NSBluetoothPeripheralUsageDescription", ["PERMISSION_BLUETOOTH"]), + ( + "NSUserTrackingUsageDescription", + ["PERMISSION_APP_TRACKING_TRANSPARENCY"], + ), + ("UNAuthorizationOptionCriticalAlert", ["PERMISSION_CRITICAL_ALERTS"]), + ("NSSiriUsageDescription", ["PERMISSION_ASSISTANT"]), + ], + ) + def test_usage_description_enables_its_macros(self, key, macros): + """Declaring a usage description compiles in its permission's handler, + without which requesting that permission fails at runtime.""" + assert _podfile_macros({key: "Why"}) == macros + + @pytest.mark.parametrize( + ("bundle", "macros"), + [ + ("location", ["PERMISSION_LOCATION", "PERMISSION_LOCATION_WHENINUSE"]), + ("camera", ["PERMISSION_CAMERA"]), + ("microphone", ["PERMISSION_MICROPHONE"]), + ("photo_library", ["PERMISSION_PHOTOS"]), + ("biometric", []), + ], + ) + def test_permission_bundle_enables_its_macros(self, bundle, macros): + """A permission bundle's entries enable the macros that bundle needs.""" + cmd = _settings_command({"tool.flet.permissions": [bundle]}) + assert _podfile_macros(cmd.get_info_plist(impeller=True)) == macros + + +class TestSigningSettings: + def test_defaults(self): + """Without settings, the app exports for debugging and isn't signed.""" + assert _settings_command().get_ios_signing() == { + "export_method": "debugging", + "provisioning_profile": None, + "signing_certificate": None, + "team_id": None, + "export_options": {}, + } + + def test_export_method_cli_overrides_pyproject(self): + """`--ios-export-method` overrides `[tool.flet.ios].export_method`.""" + cmd = _settings_command( + {"tool.flet.ios.export_method": "enterprise"}, + argv=["--ios-export-method", "app-store-connect"], + ) + assert cmd.get_ios_signing()["export_method"] == "app-store-connect" + + def test_export_method_from_pyproject(self): + """`[tool.flet.ios].export_method` overrides the default.""" + cmd = _settings_command({"tool.flet.ios.export_method": "enterprise"}) + assert cmd.get_ios_signing()["export_method"] == "enterprise" + + @pytest.mark.parametrize( + ("setting", "flag"), + [ + ("provisioning_profile", "--ios-provisioning-profile"), + ("signing_certificate", "--ios-signing-certificate"), + ("team_id", "--ios-team-id"), + ], + ) + def test_resolution_order(self, setting, flag): + """Each setting comes from the CLI, then the export method's table, then + `[tool.flet.ios]`.""" + common = {f"tool.flet.ios.{setting}": "common"} + method = { + "tool.flet.ios.export_methods": {"debugging": {setting: "method"}}, + } + assert _settings_command(common).get_ios_signing()[setting] == "common" + assert ( + _settings_command({**common, **method}).get_ios_signing()[setting] + == "method" + ) + assert ( + _settings_command( + {**common, **method}, argv=[flag, "cli"] + ).get_ios_signing()[setting] + == "cli" + ) + + def test_tables_of_other_export_methods_are_ignored(self): + """Only the table of the selected export method applies.""" + cmd = _settings_command( + { + "tool.flet.ios.export_method": "enterprise", + "tool.flet.ios.export_methods": {"debugging": {"team_id": "OTHER"}}, + } + ) + assert cmd.get_ios_signing()["team_id"] is None + + def test_export_method_options_replace_the_common_ones(self): + """The export method's options replace `[tool.flet.ios].export_options`.""" + cmd = _settings_command( + { + "tool.flet.ios.export_options": {"uploadSymbols": False, "a": "b"}, + "tool.flet.ios.export_methods": { + "debugging": {"export_options": {"uploadSymbols": True}} + }, + } + ) + assert cmd.get_ios_signing()["export_options"] == {"uploadSymbols": True} + + def test_export_options_must_be_a_table(self): + """Export options that aren't a table are rejected.""" + cmd = _settings_command({"tool.flet.ios.export_options": "thinning"}) + with pytest.raises(Exit, match="Invalid iOS export options"): + cmd.get_ios_signing() + + def test_unsupported_export_option_value_fails(self): + """A value plists cannot hold is rejected with its key.""" + cmd = _settings_command( + {"tool.flet.ios.export_options": {"when": datetime.date(2030, 1, 1)}} + ) + with pytest.raises(Exit, match="value type for when"): + cmd.get_ios_signing() + + +class TestProjectTemplate: + @pytest.mark.parametrize( + ("value", "line"), + [ + ({"ios_team_id": "TEAM123456"}, 'DEVELOPMENT_TEAM = "TEAM123456";'), + ( + {"ios_signing_certificate": "Apple Distribution"}, + 'CODE_SIGN_IDENTITY = "Apple Distribution";', + ), + ( + {"ios_provisioning_profile": "Store Profile"}, + 'PROVISIONING_PROFILE_SPECIFIER = "Store Profile";', + ), + ], + ) + def test_signing_settings_apply_to_every_app_configuration(self, value, line): + """A signing setting is written to the app's three build configurations, + and is left out when it isn't set.""" + pbxproj = "Runner.xcodeproj/project.pbxproj" + assert _render_template(pbxproj, **value).count(line) == 3 + assert line.split('"')[0] not in _render_template(pbxproj) + + def test_bundle_id_and_product_name(self): + """The bundle ID and artifact name reach every app configuration, and the + test target gets a bundle ID of its own.""" + rendered = _render_template( + "Runner.xcodeproj/project.pbxproj", + bundle_id="com.acme.app", + artifact_name="Acme App", + ) + assert rendered.count("PRODUCT_BUNDLE_IDENTIFIER = com.acme.app;") == 3 + assert ( + rendered.count("PRODUCT_BUNDLE_IDENTIFIER = com.acme.app.RunnerTests;") == 3 + ) + assert rendered.count('PRODUCT_NAME = "Acme App";') == 3 + + +class TestScheme: + def test_scheme_is_valid_for_any_artifact_name(self): + """The scheme doesn't hold the artifact name: Flutter's Swift Package Manager + migration copies its `BuildableName` back unescaped, so a `&` in that name + would make the scheme unreadable and fail the build.""" + rendered = _render_template( + "Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme", + artifact_name="Tom & Jerry", + ) + names = { + reference.get("BuildableName") + for reference in ET.fromstring(rendered).iter("BuildableReference") + } + assert names == {"Runner.app", "RunnerTests.xctest"} + + +class TestExportOptionsTemplate: + @staticmethod + def _render(options=None, **values): + """Return the parsed `exportOptions.plist`.""" + return plistlib.loads( + _render_template( + "exportOptions.plist", + options={"ios_export_options": options or {}}, + **values, + ).encode() + ) + + def test_signing_settings(self): + """The method, profile, team and certificate are written, with the profile + under the app's bundle ID.""" + assert self._render( + ios_export_method="app-store-connect", + ios_provisioning_profile="Store Profile", + ios_team_id="TEAM123456", + ios_signing_certificate="Apple Distribution", + ) == { + "method": "app-store-connect", + "provisioningProfiles": {"com.example.app": "Store Profile"}, + "teamID": "TEAM123456", + "signingCertificate": "Apple Distribution", + } + + def test_unset_team_and_certificate_are_left_out(self): + """Without a team or certificate, their keys are left out.""" + assert set(self._render(ios_provisioning_profile="Store Profile")) == { + "method", + "provisioningProfiles", + } + + def test_signing_settings_are_escaped(self): + """A profile name with XML characters still makes a valid plist.""" + plist = self._render(ios_provisioning_profile="R&D ") + assert plist["provisioningProfiles"]["com.example.app"] == "R&D " + + def test_documented_example(self): + """The example of the publishing docs makes a valid plist.""" + options = { + "uploadSymbols": False, + "compileBitcode": False, + "thinning": "", + "manifest": { + "appURL": "https://example.com/app.ipa", + "displayImageURL": "https://example.com/icon57.png", + "fullSizeImageURL": "https://example.com/icon512.png", + }, + } + plist = self._render(options, ios_provisioning_profile="Store Profile") + assert {key: plist[key] for key in options} == options + + def test_values_keep_their_types_and_are_escaped(self): + """Every plist value type survives the build, and keys and text are + escaped and never rendered as Jinja.""" + options = { + "text": "Tom & Jerry <{{ x }}>", + "tom&jerry": "key", + "count": 3, + "ratio": 0.5, + "enabled": True, + "table": {"nested": ["a", 1, 0.5, False, {"deep": "b"}]}, + } + plist = self._render(options, ios_provisioning_profile="Store Profile") + assert {key: plist[key] for key in options} == options diff --git a/sdk/python/packages/flet-cli/tests/test_build_macos.py b/sdk/python/packages/flet-cli/tests/test_build_macos.py new file mode 100644 index 0000000000..38a4130ca6 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_build_macos.py @@ -0,0 +1,364 @@ +"""Tests for the settings and templates of `flet build macos`.""" + +import argparse +import datetime +import plistlib +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional + +import pytest +from cookiecutter.prompt import prompt_for_config +from jinja2 import Environment, StrictUndefined + +from flet_cli.commands.build_base import BaseBuildCommand + +MACOS_TEMPLATE_DIR = ( + Path(__file__).resolve().parents[3] + / "templates/build/{{cookiecutter.out_dir}}/macos" +) +DEFAULT_ENTITLEMENTS = { + "com.apple.security.app-sandbox": False, + "com.apple.security.cs.allow-jit": True, + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.network.client": True, + "com.apple.security.network.server": True, + "com.apple.security.files.user-selected.read-write": True, +} +PBXPROJ = "Runner.xcodeproj/project.pbxproj" +SCHEME = "Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme" + + +class Exit(Exception): + """Captures `cleanup(code, message)` calls, which normally sys.exit.""" + + def __init__(self, code, message): + self.code = code + self.message = message + super().__init__(f"cleanup({code}): {message}") + + +def _settings_command(settings=None, argv=()): + """Create a macOS build command with the real `flet build` options parsed from + `argv`, whose configuration errors raise `Exit`.""" + parser = argparse.ArgumentParser(add_help=False) + cmd = BaseBuildCommand(parser) + cmd.options = parser.parse_args(list(argv)) + values = settings or {} + cmd.get_pyproject = lambda key=None: values.get(key) + cmd.package_platform = "Darwin" + cmd.config_platform = "macos" + + def cleanup(code, message=None, **kwargs): + raise Exit(code, message) + + cmd.cleanup = cleanup + return cmd + + +def _render_template(name: str, options: Optional[dict] = None, **values) -> str: + """Render a macOS template file from a context processed by cookiecutter, + like a real build; `values` and `options` replace the build's settings.""" + context = prompt_for_config( + { + "cookiecutter": { + "product_name": "Example", + "artifact_name": "Example", + "bundle_id": "com.example.app", + "copyright": "Copyright (c) 2026 Example", + "flutter": {"dependencies": []}, + **values, + "_options": { + "info_plist": {}, + "macos_entitlements": {}, + **(options or {}), + }, + } + }, + no_input=True, + ) + return ( + Environment(undefined=StrictUndefined, keep_trailing_newline=True) + .from_string((MACOS_TEMPLATE_DIR / name).read_text()) + .render(cookiecutter=context) + ) + + +class TestInfoPlistSettings: + def test_no_settings_add_no_entries(self): + """Without settings, the build adds nothing to `Info.plist`.""" + assert _settings_command().get_info_plist(impeller=True) == {} + + def test_permission_bundle_adds_its_macos_entries(self): + """A permission bundle adds its macOS entries, not its iOS ones.""" + cmd = _settings_command(argv=["--permissions", "location"]) + assert set(cmd.get_info_plist(impeller=True)) == {"NSLocationUsageDescription"} + + def test_platform_permissions_replace_the_common_ones(self): + """`[tool.flet.macos].permissions` replaces `[tool.flet].permissions`.""" + cmd = _settings_command( + { + "tool.flet.macos.permissions": ["camera"], + "tool.flet.permissions": ["microphone"], + } + ) + assert set(cmd.get_info_plist(impeller=True)) == {"NSCameraUsageDescription"} + + def test_info_table_overrides_permission_bundles(self): + """`[tool.flet.macos.info]` overrides a bundle's entry and adds its own.""" + cmd = _settings_command( + { + "tool.flet.macos.permissions": ["camera"], + "tool.flet.macos.info": { + "NSCameraUsageDescription": "Scan receipts", + "LSUIElement": True, + }, + } + ) + assert cmd.get_info_plist(impeller=True) == { + "NSCameraUsageDescription": "Scan receipts", + "LSUIElement": True, + } + + def test_ios_table_is_ignored_on_macos(self): + """macOS builds don't read `[tool.flet.ios.info]`.""" + cmd = _settings_command({"tool.flet.ios.info": {"UIStatusBarHidden": True}}) + assert cmd.get_info_plist(impeller=True) == {} + + def test_cli_entries_override_the_table_and_keep_their_types(self): + """`--info-plist` entries override the table and get their value types.""" + cmd = _settings_command( + {"tool.flet.macos.info": {"Name": "table", "Kept": "table"}}, + argv=[ + "--info-plist", + "Name=cli", + "Enabled=true", + "Count=32", + "Ratio=0.5", + 'Modes=["basic", "advanced"]', + "Table={ Nested = false }", + ], + ) + assert cmd.get_info_plist(impeller=True) == { + "Name": "cli", + "Kept": "table", + "Enabled": True, + "Count": 32, + "Ratio": 0.5, + "Modes": ["basic", "advanced"], + "Table": {"Nested": False}, + } + + def test_impeller_off_adds_its_key(self): + """Turning Impeller off is written to `Info.plist`.""" + assert _settings_command().get_info_plist(impeller=False) == { + "FLTEnableImpeller": False + } + + @pytest.mark.parametrize( + ("settings", "argv"), + [ + ({"tool.flet.macos.info": {"FLTEnableImpeller": True}}, ()), + ({}, ("--info-plist", "FLTEnableImpeller=true")), + ], + ) + def test_explicit_entry_overrides_the_impeller_key(self, settings, argv): + """An explicit `FLTEnableImpeller` entry wins over the Impeller setting.""" + cmd = _settings_command(settings, argv=argv) + assert cmd.get_info_plist(impeller=False) == {"FLTEnableImpeller": True} + + def test_malformed_cli_entry_fails(self): + """An `--info-plist` entry without `=` is rejected.""" + cmd = _settings_command(argv=["--info-plist", "Name"]) + with pytest.raises(Exit, match="Invalid Info.plist option: Name"): + cmd.get_info_plist(impeller=True) + + def test_unsupported_value_type_fails(self): + """A value plists cannot hold is rejected with its key.""" + cmd = _settings_command( + {"tool.flet.macos.info": {"When": datetime.date(2030, 1, 1)}} + ) + with pytest.raises(Exit, match="Unsupported Info.plist value type for When"): + cmd.get_info_plist(impeller=True) + + +class TestEntitlementSettings: + def test_defaults(self): + """Without settings, the app gets Flet's default entitlements.""" + assert _settings_command().get_macos_entitlements() == DEFAULT_ENTITLEMENTS + + @pytest.mark.parametrize( + ("bundle", "added"), + [ + ("location", {"com.apple.security.personal-information.location": True}), + ("camera", {"com.apple.security.device.camera": True}), + ("microphone", {"com.apple.security.device.audio-input": True}), + ( + "photo_library", + {"com.apple.security.personal-information.photos-library": True}, + ), + ("biometric", {}), + ], + ) + def test_permission_bundle_adds_its_entitlements(self, bundle, added): + """A permission bundle adds its entitlements to the defaults.""" + cmd = _settings_command(argv=["--permissions", bundle]) + assert cmd.get_macos_entitlements() == {**DEFAULT_ENTITLEMENTS, **added} + + def test_table_overrides_defaults_and_bundles(self): + """`[tool.flet.macos.entitlement]` overrides defaults and bundles.""" + cmd = _settings_command( + { + "tool.flet.macos.permissions": ["camera"], + "tool.flet.macos.entitlement": { + "com.apple.security.app-sandbox": True, + "com.apple.security.device.camera": False, + }, + } + ) + assert cmd.get_macos_entitlements() == { + **DEFAULT_ENTITLEMENTS, + "com.apple.security.app-sandbox": True, + "com.apple.security.device.camera": False, + } + + def test_cli_entries_override_the_table_and_keep_their_types(self): + """`--macos-entitlements` entries override the table and get their types.""" + cmd = _settings_command( + {"tool.flet.macos.entitlement": {"com.example.name": "table"}}, + argv=[ + "--macos-entitlements", + "com.example.name=cli", + "com.apple.security.app-sandbox=true", + 'com.example.groups=["a", "b"]', + ], + ) + assert cmd.get_macos_entitlements() == { + **DEFAULT_ENTITLEMENTS, + "com.apple.security.app-sandbox": True, + "com.example.name": "cli", + "com.example.groups": ["a", "b"], + } + + def test_malformed_cli_entry_fails(self): + """A `--macos-entitlements` entry without `=` is rejected.""" + cmd = _settings_command(argv=["--macos-entitlements", "com.example.name"]) + with pytest.raises(Exit, match="Invalid macOS entitlement option"): + cmd.get_macos_entitlements() + + def test_unsupported_value_type_fails(self): + """A value plists cannot hold is rejected with its key.""" + cmd = _settings_command( + {"tool.flet.macos.entitlement": {"com.example.when": datetime.date.today()}} + ) + with pytest.raises(Exit, match="value type for com.example.when"): + cmd.get_macos_entitlements() + + +class TestInfoPlistTemplate: + def test_product_name_is_written_escaped(self): + """The product name is the display name, escaped; the bundle name comes + from the build setting that follows the artifact name.""" + plist = plistlib.loads( + _render_template("Runner/Info.plist", product_name="Tom & ").encode() + ) + assert plist["CFBundleDisplayName"] == "Tom & " + assert plist["CFBundleName"] == "$(PRODUCT_NAME)" + assert plist["NSHumanReadableCopyright"] == "$(PRODUCT_COPYRIGHT)" + + def test_entries_keep_their_types_and_are_escaped(self): + """Every plist value type survives the build, and keys and text are + escaped and never rendered as Jinja.""" + entries = { + "Text": "Tom & Jerry <{{ x }}>", + "Tom&Jerry": "key", + "Enabled": True, + "Disabled": False, + "Count": 3, + "Ratio": 0.5, + "Table": {"Nested": ["a", 1, 0.5, True, {"Deep": "b"}]}, + } + plist = plistlib.loads( + _render_template( + "Runner/Info.plist", options={"info_plist": entries} + ).encode() + ) + assert {key: plist[key] for key in entries} == entries + + +@pytest.mark.parametrize( + "name", ["Runner/Release.entitlements", "Runner/DebugProfile.entitlements"] +) +class TestEntitlementsTemplates: + def test_file_holds_the_default_entitlements(self, name): + """Both entitlements files hold what the settings resolve to.""" + entitlements = _settings_command().get_macos_entitlements() + rendered = _render_template(name, options={"macos_entitlements": entitlements}) + assert plistlib.loads(rendered.encode()) == DEFAULT_ENTITLEMENTS + + def test_entries_keep_their_types_and_are_escaped(self, name): + """Every plist value type survives the build, and keys and text are + escaped and never rendered as Jinja.""" + entitlements = { + "com.example.text": "Tom & Jerry <{{ x }}>", + "com.example&key": "key", + "com.example.enabled": True, + "com.example.count": 32, + "com.example.ratio": 0.5, + "com.example.table": {"nested": ["a", 1, False, {"deep": "b"}]}, + } + rendered = _render_template(name, options={"macos_entitlements": entitlements}) + assert plistlib.loads(rendered.encode()) == entitlements + + +class TestAppInfoConfig: + def test_names_and_copyright(self): + """The artifact name, bundle ID and copyright reach the build settings.""" + rendered = _render_template( + "Runner/Configs/AppInfo.xcconfig", + artifact_name="Tom & Jerry", + bundle_id="com.acme.app", + copyright="Copyright (c) 2026 Acme", + ) + lines = rendered.splitlines() + assert "PRODUCT_NAME = Tom & Jerry" in lines + assert "PRODUCT_BUNDLE_IDENTIFIER = com.acme.app" in lines + assert ( + "PRODUCT_COPYRIGHT = Copyright (c) 2026 Acme. All rights reserved." in lines + ) + + +class TestProjectTemplate: + def test_artifact_name_names_the_app(self): + """The app's file reference and the test host follow the artifact name.""" + rendered = _render_template(PBXPROJ, artifact_name="Acme App") + assert 'path = "Acme App.app";' in rendered + assert rendered.count('"$(BUILT_PRODUCTS_DIR)/Acme App.app/') == 3 + + def test_test_target_gets_its_own_bundle_id(self): + """The test target's bundle ID derives from the app's in every configuration.""" + rendered = _render_template(PBXPROJ, bundle_id="com.acme.app") + assert ( + rendered.count("PRODUCT_BUNDLE_IDENTIFIER = com.acme.app.RunnerTests;") == 3 + ) + + @pytest.mark.parametrize( + ("dependencies", "expected"), [([], 0), (["flet_rive"], 3)] + ) + def test_rive_setup_phase_only_with_flet_rive(self, dependencies, expected): + """The Rive setup build phase is only added for apps using `flet_rive`.""" + rendered = _render_template(PBXPROJ, flutter={"dependencies": dependencies}) + assert rendered.count("Rive Native Setup") == expected + + +class TestScheme: + def test_scheme_is_valid_for_any_artifact_name(self): + """The scheme doesn't hold the artifact name: Flutter's Swift Package Manager + migration copies its `BuildableName` back unescaped, so a `&` in that name + would make the scheme unreadable and fail the build.""" + rendered = _render_template(SCHEME, artifact_name="Tom & Jerry") + names = { + reference.get("BuildableName") + for reference in ET.fromstring(rendered).iter("BuildableReference") + } + assert names == {"Runner.app", "RunnerTests.xctest"} diff --git a/sdk/python/templates/build/cookiecutter.json b/sdk/python/templates/build/cookiecutter.json index 083170367b..2593f35e65 100644 --- a/sdk/python/templates/build/cookiecutter.json +++ b/sdk/python/templates/build/cookiecutter.json @@ -19,7 +19,6 @@ "ios_provisioning_profile": "", "ios_export_method": "", "ios_signing_certificate": "", - "ios_export_options": {}, "ios_team_id": "", "package_name": "{{ cookiecutter.bundle_id.rsplit('.', 1)[-1].replace('-', '_') }}", "kotlin_dir": "{{ cookiecutter.org_name_2.replace('.', cookiecutter.sep) }}{{ cookiecutter.sep }}{{ cookiecutter.package_name }}{{ cookiecutter.sep }}", diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 75a274b2ff..a387d4538e 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/exportOptions.plist b/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/exportOptions.plist index 87a970021f..eeb566138e 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/exportOptions.plist +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/ios/exportOptions.plist @@ -3,30 +3,32 @@ method - {{ cookiecutter.ios_export_method }} + {{ cookiecutter.ios_export_method | e }} provisioningProfiles - {{ cookiecutter.bundle_id }} - {{ cookiecutter.ios_provisioning_profile }} + {{ cookiecutter.bundle_id | e }} + {{ cookiecutter.ios_provisioning_profile | e }} {% if cookiecutter.ios_team_id %} teamID - {{ cookiecutter.ios_team_id }} + {{ cookiecutter.ios_team_id | e }} {% endif %} {% if cookiecutter.ios_signing_certificate %} signingCertificate - {{ cookiecutter.ios_signing_certificate }} + {{ cookiecutter.ios_signing_certificate | e }} {% endif %} - {% macro render_dict(d) -%} - {% for key, value in d.items() -%} - {{ key }} + {% macro render_value(value) -%} {% if value is string -%} - {{ value }} + {{ value | e }} {% elif value is boolean -%} <{{ "true" if value else "false" }}/> + {% elif value is integer -%} + {{ value }} + {% elif value is float -%} + {{ value }} {% elif value is mapping -%} {{ render_dict(value) }} @@ -34,20 +36,17 @@ {% elif value is sequence -%} {# Support for lists/arrays #} {% for item in value -%} - {% if item is string -%} - {{ item }} - {% elif item is boolean -%} - <{{ "true" if item else "false" }}/> - {% elif item is mapping -%} - - {{ render_dict(item) }} - - {% endif -%} + {{ render_value(item) }} {% endfor -%} {% endif -%} + {% endmacro -%} + {% macro render_dict(d) -%} + {% for key, value in d.items() -%} + {{ key | e }} + {{ render_value(value) }} {% endfor -%} {% endmacro -%} - {{ render_dict(cookiecutter.ios_export_options) }} + {{ render_dict(cookiecutter._options.ios_export_options) }} diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 0a5b27e97f..6c65996d4f 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -65,7 +65,7 @@ @@ -82,7 +82,7 @@ diff --git a/tools/crocodocs/src/crocodocs/scripts/cross_platform_permissions.py b/tools/crocodocs/src/crocodocs/scripts/cross_platform_permissions.py index 6f91b202b4..6f93f3f5bb 100644 --- a/tools/crocodocs/src/crocodocs/scripts/cross_platform_permissions.py +++ b/tools/crocodocs/src/crocodocs/scripts/cross_platform_permissions.py @@ -1,6 +1,3 @@ -import argparse - - def _indent_block(text: str, indent: int) -> str: """Indent every line of text by the given number of spaces.""" prefix = " " * indent @@ -26,12 +23,10 @@ def _toml_value(value) -> str: def _load_cross_platform_permissions() -> dict: - """Load the cross-platform permissions dict from flet-cli's BaseBuildCommand.""" - from flet_cli.commands.build_base import BaseBuildCommand + """Load the cross-platform permissions dict from flet-cli's build command.""" + from flet_cli.commands.build_base import CROSS_PLATFORM_PERMISSIONS - parser = argparse.ArgumentParser(add_help=False) - command = BaseBuildCommand(parser) - return command.cross_platform_permissions + return CROSS_PLATFORM_PERMISSIONS def _render_toml_block(config: dict) -> str: diff --git a/website/docs/publish/ios.md b/website/docs/publish/ios.md index d224706730..8e91d96b24 100644 --- a/website/docs/publish/ios.md +++ b/website/docs/publish/ios.md @@ -352,12 +352,10 @@ Both simple and complex structures are supported: - string - boolean +- integer +- float - dictionary (nested key-value object) -- array of strings -- array of booleans -- array of dictionaries (including dictionaries that contain arrays) - -Numbers are not yet supported. +- array of any of these values ##### Example