diff --git a/CHANGELOG.md b/CHANGELOG.md index 1454fed598..c90021eb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Bug fixes +* Fix the Android SDK auto-installation failing on Windows ([#6522](https://github.com/flet-dev/flet/issues/6522), [#6892](https://github.com/flet-dev/flet/pull/6892)) by @ndonkoHenri. * Fix `InteractiveViewer` failing to render when `alignment` is set ([#6744](https://github.com/flet-dev/flet/issues/6744), [#6887](https://github.com/flet-dev/flet/pull/6887)) by @ndonkoHenri. * flet-charts: Fix `ScatterChart` failing to render when a `ScatterChartSpot` has `x_error` or `y_error` set. Error ranges are now set with the new `ChartErrorRange` type and drawn as error bars in the color of their spot ([#6885](https://github.com/flet-dev/flet/pull/6885)) by @ndonkoHenri. * Fix iOS apps that use `sqlite3` stopping with `Assertion failed: (0), function isBulkReadStatement` when run from Xcode on iOS 18 and later, by turning off Xcode's Thread Performance Checker in the scheme generated by `flet build` ([#5480](https://github.com/flet-dev/flet/issues/5480), [#6882](https://github.com/flet-dev/flet/pull/6882)) by @ndonkoHenri. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py index bd122bad8b..60a9cfb750 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py @@ -13,12 +13,15 @@ ANDROID_CMDLINE_TOOLS_VERSION = "12.0" MINIMAL_PACKAGES = [ - "cmdline-tools;latest", "platform-tools", "platforms;android-35", "build-tools;34.0.0", ] +# Written to the standard input of `sdkmanager`, which reads one answer line per +# license prompt; `--licenses` also asks once before showing the licenses. +SDKMANAGER_ANSWERS = "y\n" * 50 + class AndroidSDK: """ @@ -103,6 +106,11 @@ def cmdline_tools_bin(self, home_dir: Path) -> Optional[Path]: """ Return the `cmdline-tools` binary directory in an SDK installation. + The version installed by this CLI is preferred over `latest`, because + from version 23.0, `sdkmanager` is a wrapper around the Android CLI, + which ignores `--licenses`, collects usage metrics by default and fails + on Windows. + Args: home_dir: Android SDK home directory. @@ -111,8 +119,8 @@ def cmdline_tools_bin(self, home_dir: Path) -> Optional[Path]: """ for d in [ - home_dir / "cmdline-tools" / "latest" / "bin", home_dir / "cmdline-tools" / ANDROID_CMDLINE_TOOLS_VERSION / "bin", + home_dir / "cmdline-tools" / "latest" / "bin", ]: if d.exists(): return d @@ -322,36 +330,23 @@ def _install_package(self, home_dir: Path, package_name: str) -> int: RuntimeError: If package installation fails. """ - if home_dir.joinpath(*package_name.split(";")).exists(): + package_dir = home_dir.joinpath(*package_name.split(";")) + if package_dir.exists(): self.log(f'Android SDK package "{package_name}" is already installed') return 0 self.log(f'Installing Android SDK package "{package_name}"') p = self.run( - ( - [ - "sh", - "-c", - f'yes | "{self.sdkmanager_exe(home_dir)}" "{package_name}"', - ] - if platform.system() != "Windows" - else [ - "cmd.exe", - "/C", - "echo", - "y", - "|", - self.sdkmanager_exe(home_dir), - package_name, - ] - ), + [self.sdkmanager_exe(home_dir), package_name], env={"ANDROID_HOME": str(home_dir)}, capture_output=False, + input=SDKMANAGER_ANSWERS, ) - if p.returncode != 0: + # sdkmanager exits with 0 when it skips a package whose license is declined + if p.returncode != 0 or not package_dir.exists(): self.log(p.stderr) - raise RuntimeError("Error installing Android SDK tools") + raise RuntimeError(f'Error installing Android SDK package "{package_name}"') return 1 def _accept_licenses(self, home_dir: Path): @@ -368,23 +363,14 @@ def _accept_licenses(self, home_dir: Path): self.log("Accepting Android SDK licenses") p = self.run( - ( - ["sh", "-c", f'yes | "{self.sdkmanager_exe(home_dir)}" --licenses'] - if platform.system() != "Windows" - else [ - "cmd.exe", - "/C", - "echo", - "y", - "|", - self.sdkmanager_exe(home_dir), - "--licenses", - ] - ), + [self.sdkmanager_exe(home_dir), "--licenses"], env={"ANDROID_HOME": str(home_dir)}, capture_output=False, + input=SDKMANAGER_ANSWERS, ) - if p.returncode != 0: + # sdkmanager exits with 0 when licenses are declined + license_file = home_dir / "licenses" / "android-sdk-license" + if p.returncode != 0 or not license_file.exists(): self.log(p.stderr) raise RuntimeError("Error accepting Android SDK licenses") @@ -415,7 +401,7 @@ def get_installed_packages(self, home_dir: Path): ) return p.stdout - def run(self, args, env=None, cwd=None, capture_output=True): + def run(self, args, env=None, cwd=None, capture_output=True, input=None): """ Run a subprocess configured for Android SDK tooling. @@ -427,6 +413,8 @@ def run(self, args, env=None, cwd=None, capture_output=True): env: Optional additional environment variables. cwd: Optional working directory. Defaults to current directory. capture_output: Forwarded to subprocess helper. + input: Optional text for the subprocess's standard input. Forwarded + to subprocess helper. Returns: Subprocess result object returned by `flet_cli.utils.processes.run`. @@ -447,4 +435,5 @@ def run(self, args, env=None, cwd=None, capture_output=True): env=cmd_env, capture_output=capture_output, log=self.log, + input=input, ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/processes.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/processes.py index cce84bf5e0..b1ae6df4bc 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/processes.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/processes.py @@ -1,3 +1,4 @@ +import contextlib import os import subprocess from typing import Optional @@ -8,7 +9,14 @@ from ctypes import windll -def run(args, cwd, env: Optional[dict] = None, capture_output=True, log=None): +def run( + args, + cwd, + env: Optional[dict] = None, + capture_output=True, + log=None, + input: Optional[str] = None, +): """ Execute a subprocess command with optional streamed logging. @@ -22,6 +30,9 @@ def run(args, cwd, env: Optional[dict] = None, capture_output=True, log=None): capture_output: If `True`, run with `subprocess.run` and capture output in memory. If `False`, stream combined output line by line. log: Optional callback receiving each output line when `capture_output=False`. + input: Optional text written to the command's standard input, which is + then closed. When `capture_output=False`, it is written before any + output is read, so it must fit in the OS pipe buffer. Returns: A completed `subprocess.CompletedProcess` @@ -49,6 +60,7 @@ def run(args, cwd, env: Optional[dict] = None, capture_output=True, log=None): process = subprocess.run( args, cwd=cwd, + input=input, capture_output=True, text=True, encoding="utf-8", @@ -68,6 +80,12 @@ def run(args, cwd, env: Optional[dict] = None, capture_output=True, log=None): errors="replace", ) + if input is not None: + # The process may exit or close its standard input before reading it all + with contextlib.suppress(OSError): + process.stdin.write(input) + process.stdin.close() + try: while True: stdout_line = process.stdout.readline()