From a545d2231098ecc1aa02c3b335aca9bd98730aa7 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Thu, 14 May 2026 17:26:33 -0700 Subject: [PATCH 01/21] Add system image selection to create_emulator --- src/briefcase/integrations/android_sdk.py | 72 +++++++++++++++++++++-- src/briefcase/platforms/android/gradle.py | 10 +++- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 9513e5ef02..b742278eae 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -10,7 +10,7 @@ from datetime import datetime from pathlib import Path -from briefcase.config import PEP508_NAME_RE +from briefcase.config import PEP508_NAME_RE, FinalizedAppConfig from briefcase.exceptions import ( BriefcaseCommandError, IncompatibleToolError, @@ -23,6 +23,7 @@ from briefcase.integrations.subprocess import SubprocessArgT DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found") +ANDROID_MIN_OS_VERSION = 26 def create_avd_validator(emulators): @@ -701,6 +702,49 @@ def verify_avd(self, avd: str): except KeyError: self.tools.console.debug(f"Device {avd!r} doesn't define a skin.") + def list_available_system_images( + self, min_version: int = ANDROID_MIN_OS_VERSION + ) -> list[str]: + """Returns a sorted list of system image package identifiers available for the + current architecture and minimum Android version. + + :param min_version: The minimum Android version to include. Defaults to + ``ANDROID_MIN_OS_VERSION``. + e.g., ``["system-images;android-31;default;x86_64", "system- + images;android-34;default;x86_64", "system- + images;android-34;google_apis;x86_64"]`` + """ + + try: + output = self.tools.subprocess.check_output( + [self.sdkmanager_path, "--list"], + env=self.env, + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError( + "Unable to invoke the Android SDK manager" + ) from e + + images = [] + for line in output.splitlines(): + package = line.split("|")[0].strip() + if not package.startswith("system-images"): + continue + parts = package.split(";") + if len(parts) != 4: + continue + if parts[3] != self.emulator_abi: + continue + # parts[1] is e.g. "android-31"; extract the version number + try: + version = int(parts[1].split("-")[1]) + except (IndexError, ValueError): + continue + if version < min_version: + continue + images.append(package) + return sorted(set(images)) + def list_installed_system_images(self) -> set[str]: """Returns a set of installed system image package identifiers. @@ -1080,9 +1124,10 @@ def select_target_device( return device, name, avd - def create_emulator(self) -> str: + def create_emulator(self, app: FinalizedAppConfig) -> str: """Create a new Android emulator. + :param app: The config object for the app. :returns: The AVD of the newly created emulator. """ # Get the list of existing emulators @@ -1113,8 +1158,27 @@ def create_emulator(self) -> str: device_type = self.DEFAULT_DEVICE_TYPE skin = self.DEFAULT_DEVICE_SKIN - # TODO: Provide a list of options for system images. - system_image = self.DEFAULT_SYSTEM_IMAGE + # Get available images, raise an error if not found. + available_images = self.list_available_system_images( + min_version=getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION) + ) + if not available_images: + raise BriefcaseCommandError( + f"""\ +No Android system images are available for your architecture ({self.emulator_abi}). + +This may be caused by a network connectivity issue or an unsupported +architecture. Check your network connection and re-run `briefcase run android`. +""" + ) + + # Provide a list of options for system images. + system_image = self.tools.console.selection_question( + intro="Select the system image to use for the emulator:", + description="System image", + options=available_images, + default=available_images[-1], + ) self._create_emulator( avd=avd, diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index e82deb63a4..f404185ef5 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -25,7 +25,7 @@ DebuggerConnectionMode, ) from briefcase.exceptions import BriefcaseCommandError -from briefcase.integrations.android_sdk import ADB, AndroidSDK +from briefcase.integrations.android_sdk import ADB, ANDROID_MIN_OS_VERSION, AndroidSDK from briefcase.integrations.subprocess import SubprocessArgT if TYPE_CHECKING: @@ -225,11 +225,17 @@ def output_format_template_context(self, app: FinalizedAppConfig): "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", ] + try: + min_os_version = app.min_os_version + except AttributeError: + min_os_version = ANDROID_MIN_OS_VERSION + return { "version_code": version_code, "safe_formal_name": safe_formal_name(app.formal_name), "build_gradle_dependencies": {"implementation": dependencies}, "ndk": {"abi_filters": getattr(app, "android_abis", None)}, + "min_os_version": min_os_version, } def permissions_context( @@ -490,7 +496,7 @@ def run_app( # then start it. if device is None: if avd is None: - avd = self.tools.android_sdk.create_emulator() + avd = self.tools.android_sdk.create_emulator(app) else: # Ensure the system image for the requested emulator is available. # This step is only needed if the AVD already existed; you have to From f60e924e8b95f67e2b9a9cc2f4ff4475c7f9492c Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sat, 9 May 2026 22:10:44 -0700 Subject: [PATCH 02/21] Add tests for system image selection in create_emulator --- .../AndroidSDK/test__create_emulator.py | 21 ++++- .../AndroidSDK/test_create_emulator.py | 23 ++++- .../test_list_available_system_images.py | 91 +++++++++++++++++++ tests/platforms/android/gradle/test_run.py | 8 +- 4 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py diff --git a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py index 650fcc20d0..e317c97bf7 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py @@ -26,6 +26,13 @@ def android_sdk(android_sdk) -> AndroidSDK: "idleEmulator", ] ) + # Mock available system images + android_sdk.list_available_system_images = MagicMock( + return_value=[ + "system-images;android-31;default;x86_64", + "system-images;android-34;default;x86_64", + ] + ) return android_sdk @@ -311,8 +318,11 @@ def test_default_name(mock_tools, android_sdk, tmp_path): # This test doesn't validate most of the test process; # it only checks that the emulator is created with the default name. - # User provides no input; default name will be used - mock_tools.console.values = [""] + # User provides no input; default name and system image will be used. + mock_tools.console.values = [ + "", + "", + ] # Mock the initial output of an AVD config file. avd_config_path = tmp_path / "home/.android/avd/beePhone.avd/config.ini" @@ -340,8 +350,11 @@ def test_default_name_with_collisions(mock_tools, android_sdk, tmp_path): "beePhone", ] ) - # User provides no input; default name will be used - mock_tools.console.values = [""] + # User provides no input; default name and system image will be used. + mock_tools.console.values = [ + "", + "", + ] # Mock the initial output of an AVD config file. avd_config_path = tmp_path / "home/.android/avd/beePhone3.avd/config.ini" diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py index 44fa1b5b55..0767e71d4b 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py @@ -24,6 +24,13 @@ def android_sdk(android_sdk) -> AndroidSDK: "idleEmulator", ] ) + # Mock available system images + android_sdk.list_available_system_images = MagicMock( + return_value=[ + "system-images;android-31;default;x86_64", + "system-images;android-34;default;x86_64", + ] + ) return android_sdk @@ -59,6 +66,7 @@ def test_create_emulator( "invalid name", # A name with a space "annoying!", # a name with non-alpha characters "new-emulator", # A valid name! + "", # default system image selection (last in list) ] # Mock the initial output of an AVD config file. @@ -81,7 +89,7 @@ def test_create_emulator( avd="new-emulator", device_type="pixel", skin="pixel_7_pro", - system_image=f"system-images;android-31;default;{emulator_abi}", + system_image="system-images;android-34;default;x86_64", ) @@ -90,8 +98,11 @@ def test_default_name(mock_tools, android_sdk, tmp_path): # This test doesn't validate most of the test process; # it only checks that the emulator is created with the default name. - # User provides no input; default name will be used - mock_tools.console.values = [""] + # User provides no input; default name and system image will be used + mock_tools.console.values = [ + "", + "", + ] # Mock the internal emulator creation method android_sdk._create_emulator = MagicMock() @@ -116,7 +127,11 @@ def test_default_name_with_collisions(mock_tools, android_sdk, tmp_path): "beePhone", ] ) - mock_tools.console.values = [""] + # Default emulator name and default system image selection. + mock_tools.console.values = [ + "", + "", + ] # Mock the internal emulator creation method android_sdk._create_emulator = MagicMock() diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py new file mode 100644 index 0000000000..44aad16629 --- /dev/null +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -0,0 +1,91 @@ +import subprocess + +import pytest + +from briefcase.exceptions import BriefcaseCommandError + + +def test_list_available_system_images(mock_tools, android_sdk): + """Returns a sorted list of available system image package identifiers.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-34;default;x86_64 | 7 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-25;default;x86_64 | 3 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + # android-25 is filtered out (below minimum version) + assert result == [ + "system-images;android-31;default;x86_64", + "system-images;android-34;default;x86_64", + ] + mock_tools.subprocess.check_output.assert_called_once_with( + [android_sdk.sdkmanager_path, "--list"], + env=android_sdk.env, + ) + + +def test_list_available_system_images_other_abi(mock_tools, android_sdk): + """System images for other architectures are filtered out.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-34;default;x86_64 | 7 | Intel x86_64 Atom System Image\n" + " system-images;android-34;default;arm64-v8a | 7 | ARM 64 v8a System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + # Only x86_64 images returned (default test ABI is x86_64) + assert result == [ + "system-images;android-34;default;x86_64", + ] + + +def test_list_available_system_images_duplicates(mock_tools, android_sdk): + """Duplicate entries in sdkmanager output are deduplicated.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [ + "system-images;android-31;default;x86_64", + ] + + +def test_no_available_system_images(mock_tools, android_sdk): + """If no system images are available, an empty list is returned.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [] + + +def test_list_available_system_images_failure(mock_tools, android_sdk): + """If sdkmanager fails, an error is raised.""" + mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( + 1, "" + ) + + with pytest.raises(BriefcaseCommandError): + android_sdk.list_available_system_images() diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py index 6b91a0791e..a2567cab90 100644 --- a/tests/platforms/android/gradle/test_run.py +++ b/tests/platforms/android/gradle/test_run.py @@ -614,7 +614,9 @@ def test_run_created_emulator(run_command, first_app_config): run_command.run_app(first_app_config, passthrough=[]) # A new emulator was created - run_command.tools.android_sdk.create_emulator.assert_called_once_with() + run_command.tools.android_sdk.create_emulator.assert_called_once_with( + first_app_config + ) # No attempt was made to verify the AVD (it is pre-verified through # the creation process) @@ -929,7 +931,9 @@ def test_run_test_mode_created_emulator(run_command, first_app_config): ) # A new emulator was created - run_command.tools.android_sdk.create_emulator.assert_called_once_with() + run_command.tools.android_sdk.create_emulator.assert_called_once_with( + first_app_config + ) # No attempt was made to verify the AVD (it is pre-verified through # the creation process) From 11da7319c0dbe059a9b56ce43d14ee4e52aa9f7f Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sat, 9 May 2026 22:23:54 -0700 Subject: [PATCH 03/21] Add changelog entry for issue 737 --- changes/737.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/737.feature.md diff --git a/changes/737.feature.md b/changes/737.feature.md new file mode 100644 index 0000000000..37af4c468f --- /dev/null +++ b/changes/737.feature.md @@ -0,0 +1 @@ +Android emulator system images can now be selected when creating a new emulator, with options ranging from Android 26 to the latest available version. From 40a4f9e3a6e437235326f7d73af551ff2bdbb6af Mon Sep 17 00:00:00 2001 From: moondial Date: Mon, 18 May 2026 17:29:34 -0700 Subject: [PATCH 04/21] Provide selector for available android images --- src/briefcase/integrations/android_sdk.py | 36 ++++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index b742278eae..7deaddb731 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -1158,28 +1158,48 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: device_type = self.DEFAULT_DEVICE_TYPE skin = self.DEFAULT_DEVICE_SKIN - # Get available images, raise an error if not found. + # Get available images, raise an error if none found. available_images = self.list_available_system_images( min_version=getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION) ) if not available_images: raise BriefcaseCommandError( f"""\ -No Android system images are available for your architecture ({self.emulator_abi}). +No Android system images are available for your architecture +({self.emulator_abi}). This may be caused by a network connectivity issue or an unsupported architecture. Check your network connection and re-run `briefcase run android`. """ ) - # Provide a list of options for system images. - system_image = self.tools.console.selection_question( - intro="Select the system image to use for the emulator:", - description="System image", - options=available_images, - default=available_images[-1], + # Ask the user to select an Android version. + versions = sorted({img.split(";")[1].split("-")[1] for img in available_images}) + version = self.tools.console.selection_question( + intro="Select the Android version for the emulator:", + description="Android version", + options=versions, + default="31", ) + # Ask the user to select an image type for the chosen version. + image_types = sorted( + { + f"{img.split(';')[1]};{img.split(';')[2]}" + for img in available_images + if img.split(";")[1].startswith(f"android-{version}") + }, + key=lambda x: (0 if x.endswith(";default") else 1, x), + ) + image_type = self.tools.console.selection_question( + intro="Select the system image type:", + description="Image type", + options=image_types, + default=f"android-{version};default", + ) + + system_image = f"system-images;{image_type};{self.emulator_abi}" + self._create_emulator( avd=avd, device_type=device_type, From 88ce5169564c40d5108e376e37c2138e10166dca Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 19 May 2026 11:22:31 -0700 Subject: [PATCH 05/21] Filter all available system images by architecture and minimum version. --- src/briefcase/integrations/android_sdk.py | 16 +++-- .../AndroidSDK/test__create_emulator.py | 18 +++-- .../AndroidSDK/test_create_emulator.py | 65 +++++++++++++++++-- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 7deaddb731..492d912217 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -735,13 +735,15 @@ def list_available_system_images( continue if parts[3] != self.emulator_abi: continue - # parts[1] is e.g. "android-31"; extract the version number + # parts[1] is e.g. "android-31", "android-36.1", or "android-CANARY"; + # extract the version identifier and apply the minimum version floor. + version_str = parts[1].split("-")[1] try: - version = int(parts[1].split("-")[1]) - except (IndexError, ValueError): - continue - if version < min_version: - continue + if int(version_str.split(".")[0]) < min_version: + continue + except ValueError: + # Non-numeric version (e.g. CANARY, CinnamonBun) included. + pass images.append(package) return sorted(set(images)) @@ -1195,7 +1197,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: intro="Select the system image type:", description="Image type", options=image_types, - default=f"android-{version};default", + default=image_types[0], ) system_image = f"system-images;{image_type};{self.emulator_abi}" diff --git a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py index e317c97bf7..24dea04bcc 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py @@ -318,10 +318,11 @@ def test_default_name(mock_tools, android_sdk, tmp_path): # This test doesn't validate most of the test process; # it only checks that the emulator is created with the default name. - # User provides no input; default name and system image will be used. + # User provides no input; default name, system image and image type will be used. mock_tools.console.values = [ "", "", + "", ] # Mock the initial output of an AVD config file. @@ -330,8 +331,12 @@ def test_default_name(mock_tools, android_sdk, tmp_path): with avd_config_path.open("w", encoding="utf-8") as f: f.write("hw.device.name=pixel\n") + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + # Create the emulator - avd = android_sdk.create_emulator() + avd = android_sdk.create_emulator(app) # The expected device AVD was created. assert avd == "beePhone" @@ -350,10 +355,11 @@ def test_default_name_with_collisions(mock_tools, android_sdk, tmp_path): "beePhone", ] ) - # User provides no input; default name and system image will be used. + # User provides no input; default name, system image and image type will be used. mock_tools.console.values = [ "", "", + "", ] # Mock the initial output of an AVD config file. @@ -362,8 +368,12 @@ def test_default_name_with_collisions(mock_tools, android_sdk, tmp_path): with avd_config_path.open("w", encoding="utf-8") as f: f.write("hw.device.name=pixel\n") + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + # Create the emulator - avd = android_sdk.create_emulator() + avd = android_sdk.create_emulator(app) # The expected device AVD was created. assert avd == "beePhone3" diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py index 0767e71d4b..b27046283a 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py @@ -29,6 +29,12 @@ def android_sdk(android_sdk) -> AndroidSDK: return_value=[ "system-images;android-31;default;x86_64", "system-images;android-34;default;x86_64", + "system-images;android-34;google_apis;x86_64", + "system-images;android-31;default;arm64-v8a", + "system-images;android-34;default;arm64-v8a", + "system-images;android-34;google_apis;arm64-v8a", + "system-images;android-CANARY;google_apis;x86_64", + "system-images;android-CinnamonBun;google_apis_playstore;x86_64", ] ) return android_sdk @@ -66,7 +72,8 @@ def test_create_emulator( "invalid name", # A name with a space "annoying!", # a name with non-alpha characters "new-emulator", # A valid name! - "", # default system image selection (last in list) + "2", # Android version selection + "1", # image type selection ] # Mock the initial output of an AVD config file. @@ -78,8 +85,12 @@ def test_create_emulator( # Mock the internal emulator creation method android_sdk._create_emulator = MagicMock() + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + # Create the emulator - avd = android_sdk.create_emulator() + avd = android_sdk.create_emulator(app) # The expected device AVD was created. assert avd == "new-emulator" @@ -89,7 +100,7 @@ def test_create_emulator( avd="new-emulator", device_type="pixel", skin="pixel_7_pro", - system_image="system-images;android-34;default;x86_64", + system_image=f"system-images;android-34;default;{emulator_abi}", ) @@ -98,17 +109,22 @@ def test_default_name(mock_tools, android_sdk, tmp_path): # This test doesn't validate most of the test process; # it only checks that the emulator is created with the default name. - # User provides no input; default name and system image will be used + # User provides no input; default name, system image and image type will be used. mock_tools.console.values = [ "", "", + "", ] # Mock the internal emulator creation method android_sdk._create_emulator = MagicMock() + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + # Create the emulator - avd = android_sdk.create_emulator() + avd = android_sdk.create_emulator(app) # The expected device AVD was created. assert avd == "beePhone" @@ -127,17 +143,52 @@ def test_default_name_with_collisions(mock_tools, android_sdk, tmp_path): "beePhone", ] ) - # Default emulator name and default system image selection. + # Default emulator name, default system image and default image type selection. mock_tools.console.values = [ "", "", + "", ] # Mock the internal emulator creation method android_sdk._create_emulator = MagicMock() + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + # Create the emulator - avd = android_sdk.create_emulator() + avd = android_sdk.create_emulator(app) # The expected device AVD was created. assert avd == "beePhone3" + + +def test_system_image_selection(mock_tools, android_sdk, tmp_path): + """The user can select an Android version and image type.""" + mock_tools.console.values = [ + "", # default emulator name + "2", # select version 34 (option 2 in the list) + "2", # select google_apis (option 2 in the list) + ] + + # Mock the internal emulator creation method + android_sdk._create_emulator = MagicMock() + + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + + # Create the emulator + avd = android_sdk.create_emulator(app) + + # The expected device AVD was created. + assert avd == "beePhone" + + # The call was made to create the emulator + android_sdk._create_emulator.assert_called_once_with( + avd="beePhone", + device_type="pixel", + skin="pixel_7_pro", + system_image="system-images;android-34;google_apis;x86_64", + ) From 48fccb985bf65abb58affec4905a1f4d3d83e7b2 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 19 May 2026 13:05:47 -0700 Subject: [PATCH 06/21] Update change file to reflect new selection in wizard. --- changes/737.feature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/737.feature.md b/changes/737.feature.md index 37af4c468f..88f811c62b 100644 --- a/changes/737.feature.md +++ b/changes/737.feature.md @@ -1 +1 @@ -Android emulator system images can now be selected when creating a new emulator, with options ranging from Android 26 to the latest available version. +Android emulator system images and image types can now be selected when creating a new emulator, with options ranging from Android 26 to the latest available version. From 2a5dc7abd1516a2078fb19b2af81951cf6163b3e Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 19 May 2026 16:27:59 -0700 Subject: [PATCH 07/21] Update tests for missing coverage of dotted versions, named versions, and no system image. --- .../AndroidSDK/test_create_emulator.py | 17 ++++++++++ .../AndroidSDK/test_list_installed_images.py | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py index b27046283a..086767f53f 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py @@ -2,6 +2,7 @@ import pytest +from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.android_sdk import AndroidSDK from briefcase.integrations.base import ToolCache @@ -192,3 +193,19 @@ def test_system_image_selection(mock_tools, android_sdk, tmp_path): skin="pixel_7_pro", system_image="system-images;android-34;google_apis;x86_64", ) + + +def test_no_available_system_images(mock_tools, android_sdk, tmp_path): + """If no system images are available, an error is raised.""" + android_sdk.list_available_system_images = MagicMock(return_value=[]) + + # User provides a name before the error is raised + mock_tools.console.values = [""] # default emulator name + + # Create a mock app + app = MagicMock() + del app.min_os_version # ensure getattr fallback is used + + # No system image detected + with pytest.raises(BriefcaseCommandError): + android_sdk.create_emulator(app) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py index a95ec1f63b..cccac49552 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py @@ -25,6 +25,40 @@ def test_list_installed_system_images(mock_tools, android_sdk): ) +def test_list_available_system_images_dotted_version(mock_tools, android_sdk): + """System images with dotted versions (e.g. android-36.1) are included.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [ + "system-images;android-36.1;default;x86_64", + ] + + +def test_list_available_system_images_named_version(mock_tools, android_sdk): + """System images with named versions (e.g. android-CANARY) are included.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [ + "system-images;android-CANARY;google_apis;x86_64", + ] + + def test_no_installed_system_images(mock_tools, android_sdk): """If no system images are installed, an empty set is returned.""" mock_tools.subprocess.check_output.return_value = ( From 11ab15c13ab272f5328b1cdc21764c92a3beb2c1 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 19 May 2026 17:33:48 -0700 Subject: [PATCH 08/21] Correct misplaced tests and add coverage for dotted, named, and malformed versions in list_available_system_images --- .../test_list_available_system_images.py | 74 +++++++++++++++++++ .../AndroidSDK/test_list_installed_images.py | 34 --------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py index 44aad16629..3d507e9823 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -30,6 +30,61 @@ def test_list_available_system_images(mock_tools, android_sdk): ) +def test_list_available_system_images_dotted_version(mock_tools, android_sdk): + """System images with dotted versions (e.g. android-36.1) are included.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [ + "system-images;android-36.1;default;x86_64", + ] + + +def test_list_available_system_images_dotted_version_below_minimum( + mock_tools, android_sdk +): + """System images with dotted versions below the minimum are filtered out.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-25.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + # android-25.1 is filtered out (below minimum version 26) + assert result == [ + "system-images;android-31;default;x86_64", + ] + + +def test_list_available_system_images_named_version(mock_tools, android_sdk): + """System images with named versions (e.g. android-CANARY) are included.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + assert result == [ + "system-images;android-CANARY;google_apis;x86_64", + ] + + def test_list_available_system_images_other_abi(mock_tools, android_sdk): """System images for other architectures are filtered out.""" mock_tools.subprocess.check_output.return_value = ( @@ -89,3 +144,22 @@ def test_list_available_system_images_failure(mock_tools, android_sdk): with pytest.raises(BriefcaseCommandError): android_sdk.list_available_system_images() + + +def test_list_available_system_images_malformed_package(mock_tools, android_sdk): + """Malformed package entries in sdkmanager output are skipped.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-34 | 7 | Malformed entry\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images() + + # Malformed entry is skipped + assert result == [ + "system-images;android-31;default;x86_64", + ] diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py index cccac49552..a95ec1f63b 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_installed_images.py @@ -25,40 +25,6 @@ def test_list_installed_system_images(mock_tools, android_sdk): ) -def test_list_available_system_images_dotted_version(mock_tools, android_sdk): - """System images with dotted versions (e.g. android-36.1) are included.""" - mock_tools.subprocess.check_output.return_value = ( - "Available Packages:\n" - " Path | Version | Description\n" - " ------- | ------- | -------\n" - " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" - " emulator | 35.4.9 | Android Emulator\n" - ) - - result = android_sdk.list_available_system_images() - - assert result == [ - "system-images;android-36.1;default;x86_64", - ] - - -def test_list_available_system_images_named_version(mock_tools, android_sdk): - """System images with named versions (e.g. android-CANARY) are included.""" - mock_tools.subprocess.check_output.return_value = ( - "Available Packages:\n" - " Path | Version | Description\n" - " ------- | ------- | -------\n" - " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" - " emulator | 35.4.9 | Android Emulator\n" - ) - - result = android_sdk.list_available_system_images() - - assert result == [ - "system-images;android-CANARY;google_apis;x86_64", - ] - - def test_no_installed_system_images(mock_tools, android_sdk): """If no system images are installed, an empty set is returned.""" mock_tools.subprocess.check_output.return_value = ( From 6f79400d68ed5f3e5721429c51ce074125e236af Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Wed, 20 May 2026 18:38:58 -0700 Subject: [PATCH 09/21] Add arm64-v8a variants to ensure converage on ARM Mac --- .../test_list_available_system_images.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py index 3d507e9823..c08fe37aa7 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -34,10 +34,11 @@ def test_list_available_system_images_dotted_version(mock_tools, android_sdk): """System images with dotted versions (e.g. android-36.1) are included.""" mock_tools.subprocess.check_output.return_value = ( "Available Packages:\n" - " Path | Version | Description\n" - " ------- | ------- | -------\n" - " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" - " emulator | 35.4.9 | Android Emulator\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " system-images;android-36.1;default;arm64-v8a | 1 | ARM64 System Image\n" + " emulator | 35.4.9 | Android Emulator\n" ) result = android_sdk.list_available_system_images() @@ -57,6 +58,7 @@ def test_list_available_system_images_dotted_version_below_minimum( " ------- | ------- | -------\n" " system-images;android-25.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;ARM64-v8a | 5 | ARM64 System Image\n" " emulator | 35.4.9 | Android Emulator\n" ) @@ -72,10 +74,11 @@ def test_list_available_system_images_named_version(mock_tools, android_sdk): """System images with named versions (e.g. android-CANARY) are included.""" mock_tools.subprocess.check_output.return_value = ( "Available Packages:\n" - " Path | Version | Description\n" - " ------- | ------- | -------\n" - " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" - " emulator | 35.4.9 | Android Emulator\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" + " system-images;android-CANARY;google_apis;arm64-v8a | 1 | Google APIs ARM64\n" + " emulator | 35.4.9 | Android Emulator\n" ) result = android_sdk.list_available_system_images() From 5644e626d5737dac83f897922483a50dfc7edbb5 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Thu, 21 May 2026 20:10:37 -0700 Subject: [PATCH 10/21] Add mock_tools fixture to lock architecture for deterministic coverage across platforms --- .../test_list_available_system_images.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py index c08fe37aa7..6f7ae5e1b6 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -3,6 +3,16 @@ import pytest from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.base import ToolCache + + +@pytest.fixture +def mock_tools(tmp_path, mock_tools) -> ToolCache: + # Lock to macOS x86_64 so architecture-independent tests are deterministic. + # Architecture filtering is tested separately in test_list_available_system_images_other_abi. + mock_tools.host_os = "Darwin" + mock_tools.host_arch = "x86_64" + return mock_tools def test_list_available_system_images(mock_tools, android_sdk): @@ -37,7 +47,6 @@ def test_list_available_system_images_dotted_version(mock_tools, android_sdk): " Path | Version | Description\n" " ------- | ------- | -------\n" " system-images;android-36.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" - " system-images;android-36.1;default;arm64-v8a | 1 | ARM64 System Image\n" " emulator | 35.4.9 | Android Emulator\n" ) @@ -54,12 +63,13 @@ def test_list_available_system_images_dotted_version_below_minimum( """System images with dotted versions below the minimum are filtered out.""" mock_tools.subprocess.check_output.return_value = ( "Available Packages:\n" - " Path | Version | Description\n" - " ------- | ------- | -------\n" - " system-images;android-25.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" - " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" - " system-images;android-31;default;ARM64-v8a | 5 | ARM64 System Image\n" - " emulator | 35.4.9 | Android Emulator\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-25.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " system-images;android-25.1;default;arm64-v8a | 1 | arm64 System Image\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;arm64-v8a | 5 | arm64 System Image\n" + " emulator | 35.4.9 | Android Emulator\n" ) result = android_sdk.list_available_system_images() @@ -76,9 +86,9 @@ def test_list_available_system_images_named_version(mock_tools, android_sdk): "Available Packages:\n" " Path | Version | Description\n" " ------- | ------- | -------\n" - " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" - " system-images;android-CANARY;google_apis;arm64-v8a | 1 | Google APIs ARM64\n" - " emulator | 35.4.9 | Android Emulator\n" + " system-images;android-CANARY;google_apis;x86_64 | 1 | Google APIs Intel x86_64\n" + " system-images;android-CANARY;google_apis;arm64-v8a | 1 | Google APIs arm64\n" + " emulator | 35.4.9 | Android Emulator\n" ) result = android_sdk.list_available_system_images() @@ -95,13 +105,13 @@ def test_list_available_system_images_other_abi(mock_tools, android_sdk): " Path | Version | Description\n" " ------- | ------- | -------\n" " system-images;android-34;default;x86_64 | 7 | Intel x86_64 Atom System Image\n" - " system-images;android-34;default;arm64-v8a | 7 | ARM 64 v8a System Image\n" + " system-images;android-34;default;arm64-v8a | 7 | arm 64 v8a System Image\n" " emulator | 35.4.9 | Android Emulator\n" ) result = android_sdk.list_available_system_images() - # Only x86_64 images returned (default test ABI is x86_64) + # Only x86_64 images returned (fixture sets host_arch to x86_64) assert result == [ "system-images;android-34;default;x86_64", ] From c979c381ca3aac4e21c77d3d290482de2159104d Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sat, 13 Jun 2026 12:00:12 -0700 Subject: [PATCH 11/21] Remove default argument from list_available_system_images --- src/briefcase/integrations/android_sdk.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 492d912217..17b1a1931f 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -702,17 +702,13 @@ def verify_avd(self, avd: str): except KeyError: self.tools.console.debug(f"Device {avd!r} doesn't define a skin.") - def list_available_system_images( - self, min_version: int = ANDROID_MIN_OS_VERSION - ) -> list[str]: + def list_available_system_images(self, min_api_level: int) -> list[str]: """Returns a sorted list of system image package identifiers available for the current architecture and minimum Android version. - :param min_version: The minimum Android version to include. Defaults to - ``ANDROID_MIN_OS_VERSION``. - e.g., ``["system-images;android-31;default;x86_64", "system- - images;android-34;default;x86_64", "system- - images;android-34;google_apis;x86_64"]`` + e.g., ``{"system-images;android-31;default;x86_64"}`` + + :param min_api_level: The minimum Android API level to include. """ try: @@ -739,7 +735,7 @@ def list_available_system_images( # extract the version identifier and apply the minimum version floor. version_str = parts[1].split("-")[1] try: - if int(version_str.split(".")[0]) < min_version: + if int(version_str.split(".")[0]) < min_api_level: continue except ValueError: # Non-numeric version (e.g. CANARY, CinnamonBun) included. @@ -1162,7 +1158,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: # Get available images, raise an error if none found. available_images = self.list_available_system_images( - min_version=getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION) + min_api_level=getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION) ) if not available_images: raise BriefcaseCommandError( From 290a0d9fc2fcde54742bf35883b28ef80b0811a5 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sat, 13 Jun 2026 12:00:45 -0700 Subject: [PATCH 12/21] Add test for custom API level filtering in list_available_system_images --- .../test_list_available_system_images.py | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py index 6f7ae5e1b6..f345fe0042 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -3,6 +3,7 @@ import pytest from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.android_sdk import ANDROID_MIN_OS_VERSION from briefcase.integrations.base import ToolCache @@ -27,7 +28,9 @@ def test_list_available_system_images(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) # android-25 is filtered out (below minimum version) assert result == [ @@ -40,6 +43,28 @@ def test_list_available_system_images(mock_tools, android_sdk): ) +def test_list_available_system_images_custom_min_api_level(mock_tools, android_sdk): + """A custom minimum version filters out images below that version.""" + mock_tools.subprocess.check_output.return_value = ( + "Available Packages:\n" + " Path | Version | Description\n" + " ------- | ------- | -------\n" + " system-images;android-26;default;x86_64 | 3 | Intel x86_64 Atom System Image\n" + " system-images;android-27;default;x86_64 | 3 | Intel x86_64 Atom System Image\n" + " system-images;android-28;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" + " emulator | 35.4.9 | Android Emulator\n" + ) + + result = android_sdk.list_available_system_images(min_api_level=28) + + # android-26 and android-27 are filtered out (below custom minimum of 28) + assert result == [ + "system-images;android-28;default;x86_64", + "system-images;android-31;default;x86_64", + ] + + def test_list_available_system_images_dotted_version(mock_tools, android_sdk): """System images with dotted versions (e.g. android-36.1) are included.""" mock_tools.subprocess.check_output.return_value = ( @@ -50,7 +75,9 @@ def test_list_available_system_images_dotted_version(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) assert result == [ "system-images;android-36.1;default;x86_64", @@ -72,7 +99,9 @@ def test_list_available_system_images_dotted_version_below_minimum( " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) # android-25.1 is filtered out (below minimum version 26) assert result == [ @@ -91,7 +120,9 @@ def test_list_available_system_images_named_version(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) assert result == [ "system-images;android-CANARY;google_apis;x86_64", @@ -109,7 +140,9 @@ def test_list_available_system_images_other_abi(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) # Only x86_64 images returned (fixture sets host_arch to x86_64) assert result == [ @@ -128,7 +161,9 @@ def test_list_available_system_images_duplicates(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) assert result == [ "system-images;android-31;default;x86_64", @@ -144,7 +179,9 @@ def test_no_available_system_images(mock_tools, android_sdk): " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) assert result == [] @@ -156,7 +193,7 @@ def test_list_available_system_images_failure(mock_tools, android_sdk): ) with pytest.raises(BriefcaseCommandError): - android_sdk.list_available_system_images() + android_sdk.list_available_system_images(min_api_level=ANDROID_MIN_OS_VERSION) def test_list_available_system_images_malformed_package(mock_tools, android_sdk): @@ -170,7 +207,9 @@ def test_list_available_system_images_malformed_package(mock_tools, android_sdk) " emulator | 35.4.9 | Android Emulator\n" ) - result = android_sdk.list_available_system_images() + result = android_sdk.list_available_system_images( + min_api_level=ANDROID_MIN_OS_VERSION + ) # Malformed entry is skipped assert result == [ From b0c020a2db4d52d8111df19a9f316f58cd6b79ed Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sun, 14 Jun 2026 07:27:05 -0700 Subject: [PATCH 13/21] Update ANDROID_MIN_OS_VERSION to 24 to match the template default --- src/briefcase/integrations/android_sdk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 17b1a1931f..18c6e8947c 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -23,7 +23,7 @@ from briefcase.integrations.subprocess import SubprocessArgT DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found") -ANDROID_MIN_OS_VERSION = 26 +ANDROID_MIN_OS_VERSION = 24 def create_avd_validator(emulators): From a4cb82d4d100f8916e78d296070911f1ee25adf5 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Sun, 14 Jun 2026 08:15:08 -0700 Subject: [PATCH 14/21] Add _parse_system_image helper and refactor list_available_system_images --- src/briefcase/integrations/android_sdk.py | 29 +++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 18c6e8947c..6188edaf79 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -40,6 +40,18 @@ def _validate_avd_name(avd): return _validate_avd_name +def _parse_system_image(package: str): + """Parse a system image package identifier into its components. + + :param package: e.g. ``"system-images;android-31;default;x86_64"`` + :returns: A tuple of (api_level, tag, abi) or None if invalid. + """ + parts = package.split(";") + if len(parts) != 4 or parts[0] != "system-images": + return None + return parts[1], parts[2], parts[3] + + class AndroidDeviceNotAuthorized(BriefcaseCommandError): def __init__(self, device): self.device = device @@ -724,21 +736,18 @@ def list_available_system_images(self, min_api_level: int) -> list[str]: images = [] for line in output.splitlines(): package = line.split("|")[0].strip() - if not package.startswith("system-images"): - continue - parts = package.split(";") - if len(parts) != 4: + parsed = _parse_system_image(package) + if parsed is None: continue - if parts[3] != self.emulator_abi: + api_level, _, abi = parsed + if abi != self.emulator_abi: continue - # parts[1] is e.g. "android-31", "android-36.1", or "android-CANARY"; - # extract the version identifier and apply the minimum version floor. - version_str = parts[1].split("-")[1] + api_level_str = api_level.split("-")[1] # "android-31" -> "31" try: - if int(version_str.split(".")[0]) < min_api_level: + if int(api_level_str.split(".")[0]) < min_api_level: continue except ValueError: - # Non-numeric version (e.g. CANARY, CinnamonBun) included. + # Non-numeric API level (e.g. CANARY, CinnamonBun) always include. pass images.append(package) return sorted(set(images)) From 6da467dd782f91c3037054fa0a2de9ca803928f2 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Mon, 22 Jun 2026 09:03:46 -0700 Subject: [PATCH 15/21] Select API level and tag separately in create_emulator, and share min_api_level logic with gradle.py --- src/briefcase/integrations/android_sdk.py | 77 ++++++++++++++++------- src/briefcase/platforms/android/gradle.py | 11 ++-- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 6188edaf79..344ef3e1be 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -52,6 +52,37 @@ def _parse_system_image(package: str): return parts[1], parts[2], parts[3] +def _api_level_sort_key(api_level: str) -> tuple: + """Sort key for API level strings, ordering numeric levels (and their ext suffixes) + numerically. Non-numeric levels sort after all numeric ones. + + e.g. "android-34" -> (0, 34.0, 0) "android-34-ext9" -> (0, 34.0, 9) + "android-36.0-Baklava" -> (0, 36.0, 0) "android-CANARY" -> (1, "android- + CANARY") + """ + value = api_level.split("-", 1)[ + 1 + ] # "34", "34-ext9", "36.1", "36.0-Baklava", "CANARY" + base, _, ext = value.partition("-ext") + base = base.split("-", 1)[ + 0 + ] # strip trailing "-Name" suffix, e.g. "36.0-Baklava" -> "36.0" + try: + return 0, float(base), int(ext) if ext else 0 + except ValueError: + return 1, api_level + + +def min_api_level(app: FinalizedAppConfig) -> int: + """The minimum API level to use for the app, as an int. + + ``min_os_version`` may be configured as a string or an int, so it's + coerced to int here to avoid a TypeError when compared against + numeric API levels. + """ + return int(getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION)) + + class AndroidDeviceNotAuthorized(BriefcaseCommandError): def __init__(self, device): self.device = device @@ -1167,7 +1198,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: # Get available images, raise an error if none found. available_images = self.list_available_system_images( - min_api_level=getattr(app, "min_os_version", ANDROID_MIN_OS_VERSION) + min_api_level=min_api_level(app) ) if not available_images: raise BriefcaseCommandError( @@ -1180,32 +1211,34 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: """ ) - # Ask the user to select an Android version. - versions = sorted({img.split(";")[1].split("-")[1] for img in available_images}) - version = self.tools.console.selection_question( - intro="Select the Android version for the emulator:", - description="Android version", - options=versions, - default="31", + # Parse available images once for use in both selection questions. + parsed_images = [_parse_system_image(img) for img in available_images] + + # Ask the user to select an API level. + api_levels = sorted( + {api_level for api_level, _, abi in parsed_images}, + key=_api_level_sort_key, + ) + api_level = self.tools.console.selection_question( + intro="Select the API level for the emulator:", + description="API level", + options=api_levels, + default="android-31", ) - # Ask the user to select an image type for the chosen version. - image_types = sorted( - { - f"{img.split(';')[1]};{img.split(';')[2]}" - for img in available_images - if img.split(";")[1].startswith(f"android-{version}") - }, - key=lambda x: (0 if x.endswith(";default") else 1, x), + # Ask the user to select a tag for the chosen API level. + tags = sorted( + {tag for level, tag, _ in parsed_images if level == api_level}, + key=lambda x: (0 if x == "default" else 1, x), ) - image_type = self.tools.console.selection_question( - intro="Select the system image type:", - description="Image type", - options=image_types, - default=image_types[0], + tag = self.tools.console.selection_question( + intro="Select the system image tag:", + description="Tag", + options=tags, + default=tags[0], ) - system_image = f"system-images;{image_type};{self.emulator_abi}" + system_image = f"system-images;{api_level};{tag};{self.emulator_abi}" self._create_emulator( avd=avd, diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index f404185ef5..d73a49a18e 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -25,7 +25,11 @@ DebuggerConnectionMode, ) from briefcase.exceptions import BriefcaseCommandError -from briefcase.integrations.android_sdk import ADB, ANDROID_MIN_OS_VERSION, AndroidSDK +from briefcase.integrations.android_sdk import ( + ADB, + AndroidSDK, + min_api_level, +) from briefcase.integrations.subprocess import SubprocessArgT if TYPE_CHECKING: @@ -225,10 +229,7 @@ def output_format_template_context(self, app: FinalizedAppConfig): "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", ] - try: - min_os_version = app.min_os_version - except AttributeError: - min_os_version = ANDROID_MIN_OS_VERSION + min_os_version = min_api_level(app) return { "version_code": version_code, From 2256f4861514c6675180bd545cbda8f3b12118af Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Mon, 6 Jul 2026 19:09:22 -0700 Subject: [PATCH 16/21] Add tests for min_api_level covering str, int, and default fallback --- .../AndroidSDK/test_min_api_level.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/integrations/android_sdk/AndroidSDK/test_min_api_level.py diff --git a/tests/integrations/android_sdk/AndroidSDK/test_min_api_level.py b/tests/integrations/android_sdk/AndroidSDK/test_min_api_level.py new file mode 100644 index 0000000000..486c979a0f --- /dev/null +++ b/tests/integrations/android_sdk/AndroidSDK/test_min_api_level.py @@ -0,0 +1,25 @@ +from unittest.mock import MagicMock + +from briefcase.integrations.android_sdk import ANDROID_MIN_OS_VERSION, min_api_level + + +def test_min_api_level_int(): + """min_api_level returns the app's min_os_version when configured as an int.""" + app = MagicMock() + app.min_os_version = 28 + assert min_api_level(app) == 28 + + +def test_min_api_level_str(): + """min_api_level coerces min_os_version to int when configured as a string.""" + app = MagicMock() + app.min_os_version = "28" + assert min_api_level(app) == 28 + + +def test_min_api_level_default(): + """min_api_level falls back to ANDROID_MIN_OS_VERSION when min_os_version is not + set.""" + app = MagicMock() + del app.min_os_version + assert min_api_level(app) == ANDROID_MIN_OS_VERSION From d8cf9566256d141d80d4e26d5929e1268463e4ef Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Mon, 6 Jul 2026 19:26:45 -0700 Subject: [PATCH 17/21] Add DEFAULT_API_LEVEL and DEFAULT_TAG properties for consistency with wizard defaults --- src/briefcase/integrations/android_sdk.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 344ef3e1be..c0f213fdd0 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -226,9 +226,20 @@ def DEFAULT_DEVICE_TYPE(self) -> str: def DEFAULT_DEVICE_SKIN(self) -> str: return "pixel_7_pro" + @property + def DEFAULT_API_LEVEL(self) -> str: + return "android-31" + + @property + def DEFAULT_TAG(self) -> str: + return "default" + @property def DEFAULT_SYSTEM_IMAGE(self) -> str: - return f"system-images;android-31;default;{self.emulator_abi}" + return ( + f"system-images;{self.DEFAULT_API_LEVEL}" + f";{self.DEFAULT_TAG};{self.emulator_abi}" + ) @classmethod def sdk_path_from_env(cls, tools: ToolCache) -> tuple[str | None, str | None]: @@ -1223,7 +1234,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: intro="Select the API level for the emulator:", description="API level", options=api_levels, - default="android-31", + default=self.DEFAULT_API_LEVEL, ) # Ask the user to select a tag for the chosen API level. @@ -1235,7 +1246,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: intro="Select the system image tag:", description="Tag", options=tags, - default=tags[0], + default=self.DEFAULT_TAG, ) system_image = f"system-images;{api_level};{tag};{self.emulator_abi}" From 78cd901822bd301df5f0740d57d9e55c0a4e0749 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 7 Jul 2026 18:59:35 -0700 Subject: [PATCH 18/21] Update test data to reflect ANDROID_MIN_OS_VERSION change to 24 --- .../AndroidSDK/test_list_available_system_images.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py index f345fe0042..f1af42f74c 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_list_available_system_images.py @@ -24,7 +24,7 @@ def test_list_available_system_images(mock_tools, android_sdk): " ------- | ------- | -------\n" " system-images;android-34;default;x86_64 | 7 | Intel x86_64 Atom System Image\n" " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" - " system-images;android-25;default;x86_64 | 3 | Intel x86_64 Atom System Image\n" + " system-images;android-23;default;x86_64 | 3 | Intel x86_64 Atom System Image\n" " emulator | 35.4.9 | Android Emulator\n" ) @@ -32,7 +32,7 @@ def test_list_available_system_images(mock_tools, android_sdk): min_api_level=ANDROID_MIN_OS_VERSION ) - # android-25 is filtered out (below minimum version) + # android-23 is filtered out (below minimum API level 24) assert result == [ "system-images;android-31;default;x86_64", "system-images;android-34;default;x86_64", @@ -92,8 +92,8 @@ def test_list_available_system_images_dotted_version_below_minimum( "Available Packages:\n" " Path | Version | Description\n" " ------- | ------- | -------\n" - " system-images;android-25.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" - " system-images;android-25.1;default;arm64-v8a | 1 | arm64 System Image\n" + " system-images;android-23.1;default;x86_64 | 1 | Intel x86_64 Atom System Image\n" + " system-images;android-23.1;default;arm64-v8a | 1 | arm64 System Image\n" " system-images;android-31;default;x86_64 | 5 | Intel x86_64 Atom System Image\n" " system-images;android-31;default;arm64-v8a | 5 | arm64 System Image\n" " emulator | 35.4.9 | Android Emulator\n" @@ -103,7 +103,7 @@ def test_list_available_system_images_dotted_version_below_minimum( min_api_level=ANDROID_MIN_OS_VERSION ) - # android-25.1 is filtered out (below minimum version 26) + # android-23.1 is filtered out (below minimum API level 24) assert result == [ "system-images;android-31;default;x86_64", ] From c9f0b68ac19510efbd097aea1cb70fe212c29e92 Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 7 Jul 2026 19:21:30 -0700 Subject: [PATCH 19/21] Update comments to reflect API level and tag terminology --- .../android_sdk/AndroidSDK/test_create_emulator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py index 086767f53f..e7c9a9d2b7 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py @@ -73,8 +73,8 @@ def test_create_emulator( "invalid name", # A name with a space "annoying!", # a name with non-alpha characters "new-emulator", # A valid name! - "2", # Android version selection - "1", # image type selection + "2", # Android API level selection (android-34) + "1", # tag selection (default) ] # Mock the initial output of an AVD config file. @@ -169,8 +169,8 @@ def test_system_image_selection(mock_tools, android_sdk, tmp_path): """The user can select an Android version and image type.""" mock_tools.console.values = [ "", # default emulator name - "2", # select version 34 (option 2 in the list) - "2", # select google_apis (option 2 in the list) + "2", # select API level 34 (option 2 in the list) + "2", # tag selection (google_apis option 2 in the list) ] # Mock the internal emulator creation method From ffa16a8f871287f2f4e24f6056059a450adecc5c Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 7 Jul 2026 19:52:18 -0700 Subject: [PATCH 20/21] Add test for create_emulator when app min_os_version is present --- .../AndroidSDK/test_create_emulator.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py index e7c9a9d2b7..411c5327fe 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py @@ -209,3 +209,26 @@ def test_no_available_system_images(mock_tools, android_sdk, tmp_path): # No system image detected with pytest.raises(BriefcaseCommandError): android_sdk.create_emulator(app) + + +def test_create_emulator_with_min_os_version(mock_tools, android_sdk, tmp_path): + """create_emulator passes app's min_os_version to list_available_system_images.""" + mock_tools.console.values = [ + "new-emulator", # emulator name + "2", # API level selection (android-34) + "1", # tag selection (default) + ] + # Mock the initial output of an AVD config file. + avd_config_path = tmp_path / "home/.android/avd/new-emulator.avd/config.ini" + avd_config_path.parent.mkdir(parents=True) + with avd_config_path.open("w", encoding="utf-8") as f: + f.write("hw.device.name=pixel\n") + # Mock the internal emulator creation method + android_sdk._create_emulator = MagicMock() + # Create a mock app with min_os_version explicitly set + app = MagicMock() + app.min_os_version = 28 + # Create the emulator + android_sdk.create_emulator(app) + # Verify list_available_system_images was called with the app's min_os_version + android_sdk.list_available_system_images.assert_called_once_with(min_api_level=28) From f03a355137908b70acede875b8052c2c91b3488b Mon Sep 17 00:00:00 2001 From: Luis Palacios Date: Tue, 7 Jul 2026 19:53:04 -0700 Subject: [PATCH 21/21] Fall back to first available tag if DEFAULT_TAG is not available --- src/briefcase/integrations/android_sdk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index c0f213fdd0..2acc012cc5 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -1246,7 +1246,7 @@ def create_emulator(self, app: FinalizedAppConfig) -> str: intro="Select the system image tag:", description="Tag", options=tags, - default=self.DEFAULT_TAG, + default=self.DEFAULT_TAG if self.DEFAULT_TAG in tags else tags[0], ) system_image = f"system-images;{api_level};{tag};{self.emulator_abi}"