Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a545d22
Add system image selection to create_emulator
moondial-pal May 15, 2026
f60e924
Add tests for system image selection in create_emulator
moondial-pal May 10, 2026
11da731
Add changelog entry for issue 737
moondial-pal May 10, 2026
40a4f9e
Provide selector for available android images
May 19, 2026
88ce516
Filter all available system images by architecture and minimum version.
moondial-pal May 19, 2026
48fccb9
Update change file to reflect new selection in wizard.
moondial-pal May 19, 2026
2a5dc7a
Update tests for missing coverage of dotted versions, named versions,…
moondial-pal May 19, 2026
11ab15c
Correct misplaced tests and add coverage for dotted, named, and malfo…
moondial-pal May 20, 2026
6f79400
Add arm64-v8a variants to ensure converage on ARM Mac
moondial-pal May 21, 2026
5644e62
Add mock_tools fixture to lock architecture for deterministic coverag…
moondial-pal May 22, 2026
c979c38
Remove default argument from list_available_system_images
moondial-pal Jun 13, 2026
290a0d9
Add test for custom API level filtering in list_available_system_images
moondial-pal Jun 13, 2026
b0c020a
Update ANDROID_MIN_OS_VERSION to 24 to match the template default
moondial-pal Jun 14, 2026
a4cb82d
Add _parse_system_image helper and refactor list_available_system_images
moondial-pal Jun 14, 2026
6da467d
Select API level and tag separately in create_emulator, and share min…
moondial-pal Jun 22, 2026
2256f48
Add tests for min_api_level covering str, int, and default fallback
moondial-pal Jul 7, 2026
d8cf956
Add DEFAULT_API_LEVEL and DEFAULT_TAG properties for consistency with…
moondial-pal Jul 7, 2026
78cd901
Update test data to reflect ANDROID_MIN_OS_VERSION change to 24
moondial-pal Jul 8, 2026
c9f0b68
Update comments to reflect API level and tag terminology
moondial-pal Jul 8, 2026
ffa16a8
Add test for create_emulator when app min_os_version is present
moondial-pal Jul 8, 2026
f03a355
Fall back to first available tag if DEFAULT_TAG is not available
moondial-pal Jul 8, 2026
7af18df
Merge remote-tracking branch 'origin/main' into android-sys-img-picker
mhsmith Jul 10, 2026
c97d6f9
Merge remote-tracking branch 'origin/main' into android-sys-img-picker
mhsmith Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/737.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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.
145 changes: 140 additions & 5 deletions src/briefcase/integrations/android_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.console import Console
from briefcase.exceptions import (
BriefcaseCommandError,
Expand All @@ -24,6 +24,7 @@
from briefcase.integrations.subprocess import SubprocessArgT

DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found")
ANDROID_MIN_OS_VERSION = 24


def create_avd_validator(emulators):
Expand All @@ -40,6 +41,49 @@ 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]


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
Expand Down Expand Up @@ -199,9 +243,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]:
Expand Down Expand Up @@ -719,6 +774,44 @@ 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_api_level: int) -> list[str]:
"""Returns a sorted list of system image package identifiers available for the
current architecture and minimum Android version.

e.g., ``{"system-images;android-31;default;x86_64"}``
Comment on lines +778 to +781

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sorting seems redundant, since they'll be sorted again in the one place that calls this function. In which case, maybe it could return a set instead, which would be more consistent with list_installed_system_images, and would make the docstring example syntax correct.


:param min_api_level: The minimum Android API level to include.
"""

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()
parsed = _parse_system_image(package)
if parsed is None:
continue
api_level, _, abi = parsed
if abi != self.emulator_abi:
continue
api_level_str = api_level.split("-")[1] # "android-31" -> "31"
try:
if int(api_level_str.split(".")[0]) < min_api_level:
continue
except ValueError:
# Non-numeric API level (e.g. CANARY, CinnamonBun) always include.
pass
images.append(package)
return sorted(set(images))

def list_installed_system_images(self) -> set[str]:
"""Returns a set of installed system image package identifiers.

Expand Down Expand Up @@ -1099,9 +1192,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
Expand Down Expand Up @@ -1132,8 +1226,49 @@ 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
Comment thread
mhsmith marked this conversation as resolved.
# Get available images, raise an error if none found.
available_images = self.list_available_system_images(
min_api_level=min_api_level(app)
)
if not available_images:
raise BriefcaseCommandError(
f"""\
No Android system images are available for your architecture
Comment on lines +1234 to +1236

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use the same dedent function as your other PR.

({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`.
Comment on lines +1239 to +1240

@mhsmith mhsmith Aug 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Network connectivity seems like an unlikely cause, because if the sdkmanager failed to contact the server, it should have caused a CalledProcessError. However, it would be worth mentioning min_os_version and its value, since that's the other thing used for filtering here. There's no guarantee that CANARY will continue to exist, so setting min_os_version to a very high number could return no results.

"""
)

# 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},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{api_level for api_level, _, abi in parsed_images},
{api_level for api_level, _, _ 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=self.DEFAULT_API_LEVEL,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If min_os_version is set high enough, this may not be a valid default. There should be a fallback to default to the first item, like in the other question below.

)

# 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),
)
tag = self.tools.console.selection_question(
intro="Select the system image tag:",
description="Tag",
Comment on lines +1264 to +1266

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any precedent for calling this a "tag"? "Type" seems like a more obvious name, and has previously been used in Android Studio (see right side of screenshot).

options=tags,
default=self.DEFAULT_TAG if self.DEFAULT_TAG in tags else tags[0],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like this tags[0] fallback is unit tested.

)

system_image = f"system-images;{api_level};{tag};{self.emulator_abi}"

self._create_emulator(
avd=avd,
Expand Down
11 changes: 9 additions & 2 deletions src/briefcase/platforms/android/gradle.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
DebuggerConnectionMode,
)
from briefcase.exceptions import BriefcaseCommandError
from briefcase.integrations.android_sdk import ADB, AndroidSDK
from briefcase.integrations.android_sdk import (
ADB,
AndroidSDK,
min_api_level,
)
from briefcase.integrations.subprocess import SubprocessArgT

if TYPE_CHECKING:
Expand Down Expand Up @@ -222,11 +226,14 @@ def output_format_template_context(self, app: FinalizedAppConfig):
"androidx.swiperefreshlayout:swiperefreshlayout:1.1.0",
]

min_os_version = min_api_level(app)

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(
Expand Down Expand Up @@ -487,7 +494,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
Expand Down
35 changes: 29 additions & 6 deletions tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -311,17 +318,25 @@ 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, system image and image type 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"
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")

# Create a mock app
app = MagicMock()
del app.min_os_version # ensure getattr fallback is used
Comment thread
mhsmith marked this conversation as resolved.

# Create the emulator
avd = android_sdk.create_emulator()
avd = android_sdk.create_emulator(app)

# The expected device AVD was created.
assert avd == "beePhone"
Expand All @@ -340,17 +355,25 @@ 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, system image and image type 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"
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")

# 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"
Loading
Loading