Repository: beeware/briefcase

Issue:
Android emulator not supported on M1 hardware
**Describe the bug**

Attempting to run the android emulator on Mac M1 hardware fails with the error

```
FileNotFoundError: [Errno 2] No such file or directory: '/Users/likang/.briefcase/tools/android_sdk/emulator/emulator'.
```

**To Reproduce**
Steps to reproduce the behavior:
```bash
(venv) ➜  BeeWare cd beeware-tutorial 
(venv) ➜  beeware-tutorial cd heloworld       
(venv) ➜  heloworld briefcase dev        # run in development

[heloworld] Starting in dev mode...
Hello 
(venv) ➜  heloworld briefcase build android      # build android
[heloworld] Building Android APK...
Starting a Gradle Daemon (subsequent builds will be faster)

> Task :app:externalNativeBuildDebug
Build native-lib_armeabi-v7a
Build native-lib_arm64-v8a
Build native-lib_x86
Build native-lib_x86_64

BUILD SUCCESSFUL in 17s
31 actionable tasks: 2 executed, 29 up-to-date

[heloworld] Built android/gradle/Hello World/app/build/outputs/apk/debug/app-debug.apk
(venv) ➜  heloworld briefcase run android          # run android
Traceback (most recent call last):
  File "/Users/likang/PycharmProjects/BeeWare/venv/bin/briefcase", line 8, in <module>
    sys.exit(main())
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/__main__.py", line 11, in main
    command(**options)
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/commands/run.py", line 77, in __call__
    state = self.run_app(app, **full_options(state, options))
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/platforms/android/gradle.py", line 151, in run_app
    device, name, avd = self.android_sdk.select_target_device(
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/integrations/android_sdk.py", line 449, in select_target_device
    for avd in self.emulators():
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/integrations/android_sdk.py", line 331, in emulators
    output = self.command.subprocess.check_output(
  File "/Users/likang/PycharmProjects/BeeWare/venv/lib/python3.9/site-packages/briefcase/integrations/subprocess.py", line 85, in check_output
    return self._subprocess.check_output(
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/subprocess.py", line 424, in check_output
    return run(*popenargs, stdout=PIPE, timeout=timeout, check=True,
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/subprocess.py", line 505, in run
    with Popen(*popenargs, **kwargs) as process:
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/subprocess.py", line 951, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/subprocess.py", line 1821, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
FileNotFoundError: [Errno 2] No such file or directory: '/Users/likang/.briefcase/tools/android_sdk/emulator/emulator'
```
**Expected behavior**
run in android emulator


**Environment:**
 - Operating System: macOS Big Sur apple m1
 - Python version:3.9
 - Software versions:
   - Briefcase:0.3.5
   - Toga:
   - ...



Discussion:

Patch:
diff --git a/changes/616.feature.rst b/changes/616.feature.rst
new file mode 100644
index 00000000..12e7ec18
--- /dev/null
+++ b/changes/616.feature.rst
@@ -0,0 +1,1 @@
+The Android emulator can now be used on Apple M1 hardware.
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 716b909f..323ab9dd 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -51,12 +51,41 @@ class AndroidSDK:
         # A wrapper for testing purposes
         self.sleep = time.sleep
 
+    @property
+    def cmdline_tools_url(self):
+        """The Android SDK Command-Line Tools URL appropriate to the current operating system."""
+        platform_name = self.command.host_os.lower()
+        if self.command.host_os.lower() == 'darwin':
+            platform_name = 'mac'
+        elif self.command.host_os.lower() == 'windows':
+            platform_name = 'win'
+
+        return f"https://dl.google.com/android/repository/commandlinetools-{platform_name}-{self.cmdline_tools_version}_latest.zip"  # noqa: E501
+
+    @property
+    def cmdline_tools_path(self):
+        return self.root_path / 'cmdline-tools' / 'latest'
+
+    @property
+    def cmdline_tools_version(self):
+        # This is the version of the Android SDK Command-line tools that
+        # are current as of May 2022. These tools can generally self-update,
+        # so using an fixed download URL isn't a problem.
+        # However, if/when this version number is changed, ensure that the
+        # checks done during verification include any required upgrade
+        # steps.
+        return "8092744"
+
+    @property
+    def cmdline_tools_version_path(self):
+        return self.root_path / 'cmdline-tools' / self.cmdline_tools_version
+
     @property
     def sdkmanager_path(self):
         sdkmanager = (
             "sdkmanager.bat" if self.command.host_os == "Windows" else "sdkmanager"
         )
-        return self.root_path / "tools" / "bin" / sdkmanager
+        return self.cmdline_tools_path / "bin" / sdkmanager
 
     @property
     def adb_path(self):
@@ -68,7 +97,7 @@ class AndroidSDK:
         avdmanager = (
             "avdmanager.bat" if self.command.host_os == "Windows" else "avdmanager"
         )
-        return self.root_path / "tools" / "bin" / avdmanager
+        return self.cmdline_tools_path / "bin" / avdmanager
 
     @property
     def emulator_path(self):
@@ -87,12 +116,18 @@ class AndroidSDK:
         }
 
     @property
-    def sdk_url(self):
-        """The Android SDK URL appropriate to the current operating system."""
-        # The URLs described by the pattern below have existed since
-        # approximately 2017, and the code they download has a built-in
-        # updater. I hope they will work for many years.
-        return f"https://dl.google.com/android/repository/sdk-tools-{self.command.host_os.lower()}-4333796.zip"
+    def emulator_abi(self):
+        """The ABI to use for the Android emulator"""
+        if self.command.host_arch == "arm64":
+            if self.command.host_os == "Darwin":
+                return 'arm64-v8a'
+        if self.command.host_arch in ('x86_64', 'AMD64'):
+            return 'x86_64'
+
+        raise BriefcaseCommandError(
+            "The Android emulator does not currently support "
+            f"{self.command.host_os} {self.command.host_arch} hardware."
+        )
 
     @classmethod
     def verify(cls, command, install=True, jdk=None):
@@ -121,6 +156,12 @@ class AndroidSDK:
             if sdk.exists():
                 # Ensure licenses have been accepted
                 sdk.verify_license()
+
+                # If the user has requested debug output, output the current
+                # list of packages managed by the SDK manager
+                if command.logger.verbosity >= command.logger.DEBUG:
+                    sdk.list_packages()
+
                 return sdk
             else:
                 command.logger.warning(
@@ -139,21 +180,51 @@ class AndroidSDK:
     Briefcase will use its own SDK instance.
 
 *************************************************************************
-
-"""
-                )
+""")
 
         # Build an SDK wrapper for the Briefcase SDK instance.
+        sdk_root_path = command.tools_path / "android_sdk"
         sdk = AndroidSDK(
             command=command,
             jdk=jdk,
-            root_path=command.tools_path / "android_sdk"
+            root_path=sdk_root_path,
         )
 
         if sdk.exists():
+            # NOTE: For now, there's only one version of the cmdline-tools in the wild.
+            # If/when that ever changes, do a verification check here.
+
+            # The sdkmanager binary exists in the `latest` location, and is executable.
             # Ensure licenses have been accepted
             sdk.verify_license()
+
+            # If the user has requested debug output, output the current
+            # list of packages managed by the SDK manager
+            if command.logger.verbosity >= command.logger.DEBUG:
+                sdk.list_packages()
+
             return sdk
+        elif (sdk_root_path / "tools").exists():
+            # The legacy SDK Tools exist. Delete them.
+            command.logger.info(f"""
+*************************************************************************
+** WARNING: Upgrading Android SDK tools                                **
+*************************************************************************
+
+    Briefcase needs to replace the older Android SDK Tools with the
+    newer Android SDK Command-Line Tools. This will involve some large
+    downloads, as well as re-accepting the licenses for the Android
+    SDKs.
+
+    Any emulators created with the older Android SDK Tools will not be
+    compatible with the new tools. You will need to create new
+    emulators. Old emulators can be removed by deleting the files
+    in {sdk.avd_path} matching the emulator name.
+
+*************************************************************************
+
+""")
+            command.shutil.rmtree(sdk_root_path)
 
         if install:
             sdk.install()
@@ -185,31 +256,58 @@ class AndroidSDK:
         Download and install the Android SDK.
         """
         try:
-            sdk_zip_path = self.command.download_url(
-                url=self.sdk_url, download_path=self.command.tools_path,
+            cmdline_tools_zip_path = self.command.download_url(
+                url=self.cmdline_tools_url,
+                download_path=self.command.tools_path,
             )
         except requests_exceptions.ConnectionError:
-            raise NetworkFailure("download Android SDK")
-
+            raise NetworkFailure("download Android SDK Command-Line Tools")
+
+        # The cmdline-tools package *must* be installed as:
+        #     <sdk_path>/cmdline-tools/latest
+        #
+        # However, the zip file unpacks a top-level folder named `cmdline-tools`.
+        # So, the unpacking process is:
+        #
+        #  1. Make a <sdk_path>/cmdline-tools folder
+        #  2. Unpack the zip file into that folder, creating <sdk_path>/cmdline-tools/cmdline-tools
+        #  3. Move <sdk_path>/cmdline-tools/cmdline-tools to <sdk_path>/cmdline-tools/latest
+        #  4. Drop a marker file named <sdk_path>/cmdline-tools/<version> so we can track
+        #     the version that was installed.
+
+        self.command.logger.info("Install Android SDK Command-Line Tools...")
+        self.cmdline_tools_path.parent.mkdir(parents=True, exist_ok=True)
         try:
-            self.command.logger.info("Install Android SDK...")
-            # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
-            self.command.shutil.unpack_archive(os.fsdecode(sdk_zip_path), extract_dir=os.fsdecode(self.root_path))
+            self.command.shutil.unpack_archive(
+                cmdline_tools_zip_path,
+                extract_dir=self.cmdline_tools_path.parent
+            )
         except (shutil.ReadError, EOFError):
             raise BriefcaseCommandError(f"""\
-Unable to unpack Android SDK ZIP file. The download may have been interrupted
+Unable to unpack Android SDK Command-Line Tools ZIP file. The download may have been interrupted
 or corrupted.
 
-Delete {sdk_zip_path} and run briefcase again.
+Delete {cmdline_tools_zip_path} and run briefcase again.
 """)
 
+        # If there's an existing version of the cmdline tools (or the version marker), delete them.
+        if self.cmdline_tools_path.exists():
+            self.command.shutil.rmtree(self.cmdline_tools_path)
+        if self.cmdline_tools_version_path.exists():
+            self.command.os.unlink(self.cmdline_tools_version_path)
+
+        # Rename the top level zip content to the final name
+        (self.cmdline_tools_path.parent / "cmdline-tools").rename(self.cmdline_tools_path)
+        # Touch a file with the version that was installed.
+        self.cmdline_tools_version_path.touch()
+
         # Zip file no longer needed once unpacked.
-        sdk_zip_path.unlink()
+        cmdline_tools_zip_path.unlink()
 
         # Python zip unpacking ignores permission metadata.
         # On non-Windows, we manually fix permissions.
         if self.command.host_os != "Windows":
-            for binpath in (self.root_path / "tools" / "bin").glob("*"):
+            for binpath in (self.cmdline_tools_path / "bin").glob("*"):
                 if not self.command.os.access(binpath, self.command.os.X_OK):
                     binpath.chmod(0o755)
 
@@ -222,16 +320,31 @@ Delete {sdk_zip_path} and run briefcase again.
             # Using subprocess.run() with no I/O redirection so the user sees
             # the full output and can send input.
             self.command.subprocess.run(
-                [os.fsdecode(self.sdkmanager_path), "--update"], env=self.env, check=True,
+                [os.fsdecode(self.sdkmanager_path), "--update"],
+                env=self.env,
+                check=True,
             )
         except subprocess.CalledProcessError:
             raise BriefcaseCommandError(f"""\
-Error while reviewing Android SDK licenses. Please run this command and examine
+Error while updating the Android SDK manager. Please run this command and examine
 its output for errors.
 
-    $ {self.root_path / 'tools' / 'bin' / 'sdkmanager'} --update
+    $ {self.sdkmanager_path} --update
 """)
 
+    def list_packages(self):
+        """List the packages currently manged by the Android SDK"""
+        try:
+            # Using subprocess.run() with no I/O redirection so the user sees
+            # the full output and can send input.
+            self.command.subprocess.run(
+                [os.fsdecode(self.sdkmanager_path), "--list_installed"],
+                env=self.env,
+                check=True,
+            )
+        except subprocess.CalledProcessError:
+            raise BriefcaseCommandError("Unable to invoke the Android SDK manager")
+
     def adb(self, device):
         """Obtain an ADB instance for managing a specific device.
 
@@ -267,7 +380,7 @@ its output for errors.
 Error while reviewing Android SDK licenses. Please run this command and examine
 its output for errors.
 
-    $ {self.root_path / 'tools' / 'bin' / 'sdkmanager'} --licenses
+    $ {self.sdkmanager_path} --licenses
 """)
 
         if not license_path.exists():
@@ -283,6 +396,11 @@ connection.
         Raises an error if the emulator can't be installed.
         """
         if (self.root_path / "emulator").exists():
+            # If the user has requested debug output, output the current
+            # list of packages managed by the SDK manager
+            if self.command.logger.verbosity >= self.command.logger.DEBUG:
+                self.list_packages()
+
             return
 
         self.command.logger.info("Downloading the Android emulator and system image...")
@@ -290,10 +408,9 @@ connection.
             self.command.subprocess.run(
                 [
                     os.fsdecode(self.sdkmanager_path),
-                    "platforms;android-28",
-                    "system-images;android-28;default;x86",
-                    "emulator",
                     "platform-tools",
+                    "emulator",
+                    f"system-images;android-31;default;{self.emulator_abi}",
                 ],
                 env=self.env,
                 check=True,
@@ -596,8 +713,8 @@ An emulator named '{avd}' already exists.
                     "--verbose",
                     "create", "avd",
                     "--name", avd,
-                    "--abi", "x86",
-                    "--package", 'system-images;android-28;default;x86',
+                    "--abi", self.emulator_abi,
+                    "--package", f'system-images;android-31;default;{self.emulator_abi}',
                     "--device", device_type,
                 ],
                 env=self.env,
@@ -628,10 +745,9 @@ An emulator named '{avd}' already exists.
 
             # Unpack skin archive
             try:
-                # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
                 self.command.shutil.unpack_archive(
-                    os.fsdecode(skin_tgz_path),
-                    extract_dir=os.fsdecode(skin_path)
+                    skin_tgz_path,
+                    extract_dir=skin_path
                 )
             except (shutil.ReadError, EOFError):
                 raise BriefcaseCommandError(f"Unable to unpack {skin} device skin")
@@ -640,32 +756,59 @@ An emulator named '{avd}' already exists.
             skin_tgz_path.unlink()
 
         self.command.logger.info("Adding extra device configuration...")
-        with (
-            self.avd_path / f'{avd}.avd' / 'config.ini'
-        ).open('a') as f:
-            f.write(
-                f"""
-disk.dataPartition.size=4096M
-hw.keyboard=yes
-skin.dynamic=yes
-skin.name={skin}
-skin.path=skins/{skin}
-showDeviceFrame=yes
-"""
-            )
+        self.update_emulator_config(
+            avd,
+            {
+                "avd.id": avd,
+                "avd.name": avd,
+                "disk.dataPartition.size": "4096M",
+                "hw.keyboard": "yes",
+                "skin.dynamic": "yes",
+                "skin.name": skin,
+                "skin.path": f"skins/{skin}",
+                "showDeviceFrame": "yes",
+            }
+        )
 
-            self.command.logger.info(
-                f"""
+        self.command.logger.info(
+            f"""
 Android emulator '{avd}' created.
 
 In future, you can specify this device by running:
 
-    briefcase run android -d @{avd}
+briefcase run android -d @{avd}
 """
-            )
+        )
 
         return avd
 
+    def update_emulator_config(self, avd, updates):
+        """
+        Update the AVD configuration with specific values.
+
+        :params avd: The AVD whose config will be updated
+        :params updates: A dictionary containing the new key-value to
+            add to the device configuration.
+        """
+        # Parse the existing config into key-value pairs
+        avd_config_filename = self.avd_path / f'{avd}.avd' / 'config.ini'
+        avd_config = {}
+        with avd_config_filename.open('r') as f:
+            for line in f:
+                try:
+                    key, value = line.rstrip().split("=", 1)
+                    avd_config[key] = value
+                except ValueError:
+                    pass
+
+        # Augment the config with the new new key-values pairs
+        avd_config.update(updates)
+
+        # Write the update configuration.
+        with avd_config_filename.open('w') as f:
+            for key, value in avd_config.items():
+                f.write(f"{key}={value}\n")
+
     def start_emulator(self, avd):
         """Start an existing Android emulator.
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ea8d337b..247ac2db 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,12 +21,14 @@ def monkeypatched_print(*arg, **kwargs):
     "Allow print calls from console.py...raise an error for all other callers"
     frame = inspect.currentframe().f_back
     module = inspect.getmodule(frame.f_code)
-    if module.__name__ == "briefcase.console":
-        _print(*arg, **kwargs)
-    else:
+
+    # Disallow any use of a bare print() in the briefcase codebase
+    if module.__name__.startswith("briefcase.") and module.__name__ != "briefcase.console":
         pytest.fail(
             "print() should not be invoked directly. Use Log or Console for printing."
         )
+    else:
+        _print(*arg, **kwargs)
 
 
 @pytest.fixture(autouse=True)
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
index a52a1014..a90107a8 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_create_emulator.py
@@ -15,7 +15,10 @@ from tests.utils import FsPathMock
 def mock_sdk(tmp_path):
     command = MagicMock()
     command.home_path = tmp_path
-    command.host_platform = 'unknown'
+
+    # For default test purposes, assume we're on macOS x86_64
+    command.host_os = 'Darwin'
+    command.host_arch = 'x86_64'
 
     sdk = AndroidSDK(command, jdk=MagicMock(), root_path=tmp_path)
 
@@ -28,11 +31,24 @@ def mock_sdk(tmp_path):
     return sdk
 
 
-def test_create_emulator(mock_sdk, tmp_path):
+@pytest.mark.parametrize(
+    "host_os, host_arch, emulator_abi",
+    [
+        ("Darwin", "x86_64", "x86_64"),
+        ("Darwin", "arm64", "arm64-v8a"),
+        ("Windows", "x86_64", "x86_64"),
+        ("Linux", "x86_64", "x86_64"),
+    ]
+)
+def test_create_emulator(mock_sdk, tmp_path, host_os, host_arch, emulator_abi):
     "A new emulator can be created."
     # This test validates everything going well on first run.
     # This means the skin will be downloaded and unpacked.
 
+    # Mock the hardware and operating system to specific values
+    mock_sdk.command.host_os = host_os
+    mock_sdk.command.host_arch = host_arch
+
     # Mock the user providing several invalid names before getting it right.
     mock_sdk.command.input.side_effect = [
         'runningEmulator',
@@ -69,8 +85,8 @@ def test_create_emulator(mock_sdk, tmp_path):
             "--verbose",
             "create", "avd",
             "--name", "new-emulator",
-            "--abi", "x86",
-            "--package", 'system-images;android-28;default;x86',
+            "--abi", emulator_abi,
+            "--package", f'system-images;android-31;default;{emulator_abi}',
             "--device", "pixel",
         ],
         env=mock_sdk.env,
@@ -86,10 +102,9 @@ def test_create_emulator(mock_sdk, tmp_path):
     )
 
     # Skin is unpacked.
-    # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_sdk.command.shutil.unpack_archive.assert_called_once_with(
-        os.fsdecode(skin_tgz_path),
-        extract_dir=os.fsdecode(mock_sdk.root_path / "skins" / "pixel_3a")
+        skin_tgz_path,
+        extract_dir=mock_sdk.root_path / "skins" / "pixel_3a"
     )
 
     # Original file was deleted.
@@ -107,6 +122,10 @@ def test_create_preexisting_skins(mock_sdk, tmp_path):
     # This test validates that if skins are already cached,
     # they're not re-downloaded
 
+    # Mock the hardware and operating system
+    mock_sdk.command.host_os = 'Darwin'
+    mock_sdk.command.host_arch = 'x86_64'
+
     # Mock the user getting a valid name first time
     mock_sdk.command.input.return_value = 'new-emulator'
 
@@ -132,8 +151,8 @@ def test_create_preexisting_skins(mock_sdk, tmp_path):
             "--verbose",
             "create", "avd",
             "--name", "new-emulator",
-            "--abi", "x86",
-            "--package", 'system-images;android-28;default;x86',
+            "--abi", "x86_64",
+            "--package", 'system-images;android-31;default;x86_64',
             "--device", "pixel",
         ],
         env=mock_sdk.env,
@@ -171,8 +190,8 @@ def test_create_failure(mock_sdk):
             "--verbose",
             "create", "avd",
             "--name", "new-emulator",
-            "--abi", "x86",
-            "--package", 'system-images;android-28;default;x86',
+            "--abi", "x86_64",
+            "--package", 'system-images;android-31;default;x86_64',
             "--device", "pixel",
         ],
         env=mock_sdk.env,
@@ -199,8 +218,8 @@ def test_download_failure(mock_sdk, tmp_path):
             "--verbose",
             "create", "avd",
             "--name", "new-emulator",
-            "--abi", "x86",
-            "--package", 'system-images;android-28;default;x86',
+            "--abi", "x86_64",
+            "--package", 'system-images;android-31;default;x86_64',
             "--device", "pixel",
         ],
         env=mock_sdk.env,
@@ -248,8 +267,8 @@ def test_unpack_failure(mock_sdk, tmp_path):
             "--verbose",
             "create", "avd",
             "--name", "new-emulator",
-            "--abi", "x86",
-            "--package", 'system-images;android-28;default;x86',
+            "--abi", "x86_64",
+            "--package", 'system-images;android-31;default;x86_64',
             "--device", "pixel",
         ],
         env=mock_sdk.env,
@@ -265,10 +284,9 @@ def test_unpack_failure(mock_sdk, tmp_path):
     )
 
     # An attempt to unpack the skin was made.
-    # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_sdk.command.shutil.unpack_archive.assert_called_once_with(
-        os.fsdecode(skin_tgz_path),
-        extract_dir=os.fsdecode(mock_sdk.root_path / "skins" / "pixel_3a")
+        skin_tgz_path,
+        extract_dir=mock_sdk.root_path / "skins" / "pixel_3a"
     )
 
     # Original file wasn't deleted.
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
new file mode 100644
index 00000000..b6ba8321
--- /dev/null
+++ b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
@@ -0,0 +1,30 @@
+import os
+import subprocess
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+
+def test_list_packages(mock_sdk):
+    "sdkmanager can be use to list the packages managed by the Android SDK"
+    mock_sdk.list_packages()
+
+    mock_sdk.command.subprocess.run.assert_called_once_with(
+        [os.fsdecode(mock_sdk.sdkmanager_path), "--list_installed"],
+        env=mock_sdk.env,
+        check=True,
+    )
+
+
+def test_list_packages_failure(mock_sdk):
+    "If sdkmanager fails, an error is raised"
+    mock_sdk.command.subprocess.run.side_effect = subprocess.CalledProcessError(1, "")
+    with pytest.raises(BriefcaseCommandError):
+        mock_sdk.list_packages()
+
+    mock_sdk.command.subprocess.run.assert_called_once_with(
+        [os.fsdecode(mock_sdk.sdkmanager_path), "--list_installed"],
+        env=mock_sdk.env,
+        check=True,
+    )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_properties.py b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
index 908a26f7..3f0a4c95 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_properties.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
@@ -3,15 +3,24 @@ from pathlib import Path
 
 import pytest
 
+from briefcase.exceptions import BriefcaseCommandError
 
-def test_sdk_url(mock_sdk):
+
+@pytest.mark.parametrize(
+    "host_os, name",
+    [
+        ("windows", "win"),
+        ("Windows", "win"),
+        ("darwin", "mac"),
+        ("Darwin", "mac"),
+    ]
+)
+def test_cmdline_tools_url(mock_sdk, host_os, name):
     "Validate that the SDK URL is computed using `host_os`."
-    # We set `host_os` to a sentinel value in order to validate that
-    # `build_command.sdk_url` uses `host_os`.
-    mock_sdk.command.host_os = "sAMple"
+    mock_sdk.command.host_os = host_os
 
-    assert mock_sdk.sdk_url == (
-        "https://dl.google.com/android/repository/sdk-tools-sample-4333796.zip"
+    assert mock_sdk.cmdline_tools_url == (
+        f"https://dl.google.com/android/repository/commandlinetools-{name}-8092744_latest.zip"
     )
 
 
@@ -29,7 +38,7 @@ def test_sdkmanager_path(mock_sdk, host_os, sdkmanager_name):
     mock_sdk.command.host_os = host_os
 
     assert mock_sdk.sdkmanager_path == (
-        mock_sdk.root_path / "tools" / "bin" / sdkmanager_name
+        mock_sdk.root_path / "cmdline-tools" / "latest" / "bin" / sdkmanager_name
     )
 
 
@@ -65,7 +74,7 @@ def test_avdmanager_path(mock_sdk, host_os, avdmanager_name):
     mock_sdk.command.host_os = host_os
 
     assert mock_sdk.avdmanager_path == (
-        mock_sdk.root_path / "tools" / "bin" / avdmanager_name
+        mock_sdk.root_path / "cmdline-tools" / "latest" / "bin" / avdmanager_name
     )
 
 
@@ -102,3 +111,45 @@ def test_simple_env(mock_sdk, tmp_path):
 def test_managed_install(mock_sdk):
     "All Android SDK installs are managed"
     assert mock_sdk.managed_install
+
+
+@pytest.mark.parametrize(
+    "host_os, host_arch, emulator_abi",
+    [
+        ("Darwin", "x86_64", "x86_64"),
+        ("Darwin", "arm64", "arm64-v8a"),
+        ("Windows", "x86_64", "x86_64"),
+        ("Windows", "AMD64", "x86_64"),
+        ("Linux", "x86_64", "x86_64"),
+    ]
+)
+def test_emulator_abi(mock_sdk, host_os, host_arch, emulator_abi):
+    "The emulator API can be determined from the host OS and architecture"
+    # Mock the hardware and operating system
+    mock_sdk.command.host_os = host_os
+    mock_sdk.command.host_arch = host_arch
+
+    assert mock_sdk.emulator_abi == emulator_abi
+
+
+@pytest.mark.parametrize(
+    "host_os, host_arch",
+    [
+        ("Darwin", "powerpc"),
+        ("Windows", "arm64"),
+        ("Windows", "powerpc"),
+        ("Linux", "arm64"),
+        ("Linux", "powerpc"),
+    ]
+)
+def test_bad_emulator_abi(mock_sdk, host_os, host_arch):
+    "If the host OS/architecture isn't supported by Android, an error is raised"
+    # Mock the hardware and operating system
+    mock_sdk.command.host_os = host_os
+    mock_sdk.command.host_arch = host_arch
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=rf"The Android emulator does not currently support {host_os} {host_arch} hardware."
+    ):
+        mock_sdk.emulator_abi
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_update_emulator_config.py b/tests/integrations/android_sdk/AndroidSDK/test_update_emulator_config.py
new file mode 100644
index 00000000..aecdcc28
--- /dev/null
+++ b/tests/integrations/android_sdk/AndroidSDK/test_update_emulator_config.py
@@ -0,0 +1,76 @@
+import pytest
+
+
+@pytest.fixture
+def test_device(tmp_path):
+    "Create an AVD configuration file"
+    config_file = tmp_path / ".android" / "avd" / "testDevice.avd" / "config.ini"
+    config_file.parent.mkdir(parents=True)
+
+    # Write a default config. It contains:
+    # * blank lines
+    # * a key whose value explicitly contains an equals sign.
+    with config_file.open('w') as f:
+        f.write("""
+avd.ini.encoding=UTF-8
+hw.device.manufacturer=Google
+hw.device.name=pixel
+weird.key=good=bad
+
+PlayStore.enabled=no
+avd.name=beePhone
+disk.cachePartition=yes
+disk.cachePartition.size=42M
+""")
+
+    return config_file
+
+
+def test_update_existing(mock_sdk, test_device):
+    "Existing keys in an Android AVD config can be updated"
+    # Update 2 keys in the config
+    mock_sdk.update_emulator_config("testDevice", {
+        'avd.name': 'testDevice',
+        'disk.cachePartition.size': '37MB',
+    })
+
+    with test_device.open() as f:
+        content = f.read()
+
+    # Keys have been updated, order is preserved.
+    # Blank lines have been dropped.
+    assert content == """avd.ini.encoding=UTF-8
+hw.device.manufacturer=Google
+hw.device.name=pixel
+weird.key=good=bad
+PlayStore.enabled=no
+avd.name=testDevice
+disk.cachePartition=yes
+disk.cachePartition.size=37MB
+"""
+
+
+def test_new_content(mock_sdk, test_device):
+    "New keys can be added to an Android AVD config"
+    # Add 2 new keys to the config
+    mock_sdk.update_emulator_config("testDevice", {
+        'skin.name': 'pixel_3a',
+        'skin.path': 'skins/pixel_3a',
+    })
+
+    with test_device.open() as f:
+        content = f.read()
+
+    # New keys are appended to the end of the file
+    # Newlines have been dropped
+    assert content == """avd.ini.encoding=UTF-8
+hw.device.manufacturer=Google
+hw.device.name=pixel
+weird.key=good=bad
+PlayStore.enabled=no
+avd.name=beePhone
+disk.cachePartition=yes
+disk.cachePartition.size=42M
+skin.name=pixel_3a
+skin.path=skins/pixel_3a
+"""
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index 69e61a45..543d7a02 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -1,4 +1,5 @@
 import os
+import platform
 import shutil
 import sys
 from unittest.mock import MagicMock
@@ -6,19 +7,21 @@ from unittest.mock import MagicMock
 import pytest
 from requests import exceptions as requests_exceptions
 
+from briefcase.console import Log
 from briefcase.exceptions import (
     BriefcaseCommandError,
     MissingToolError,
     NetworkFailure
 )
 from briefcase.integrations.android_sdk import AndroidSDK
-from tests.utils import FsPathMock
 
 
 @pytest.fixture
 def mock_command(tmp_path):
     command = MagicMock()
 
+    command.logger = Log(verbosity=1)
+
     # Mock-out the `sys` module so we can mock out the Python version in some tests.
     command.sys = MagicMock()
     command.tools_path = tmp_path / "tools"
@@ -26,17 +29,31 @@ def mock_command(tmp_path):
     # Make the `os` module and `host_os` live.
     command.os = os
 
-    # Mock a host platform
-    command.host_os = 'Unknown'
-
+    # Identify the host platform
+    command.host_os = platform.system()
+    command._test_download_tag = {
+        'Windows': 'win',
+        'Darwin': 'mac',
+        'Linux': 'linux',
+    }[command.host_os]
     # Override some other modules so we can test side-effects.
     command.download_url = MagicMock()
     command.subprocess = MagicMock()
     command.shutil = MagicMock()
+    # Use the original module rmtree implementation
+    command.shutil.rmtree = shutil.rmtree
 
     return command
 
 
+def mock_unpack(filename, extract_dir):
+    # Create a file that would have been created by unpacking the archive
+    # This includes the duplicated "cmdline-tools" folder name
+    (extract_dir / "cmdline-tools" / "bin").mkdir(parents=True)
+    (extract_dir / "cmdline-tools" / "bin" / "sdkmanager").touch(mode=0o644)
+    (extract_dir / "cmdline-tools" / "bin" / "avdmanager").touch(mode=0o644)
+
+
 def accept_license(android_sdk_root_path):
     "Generate a side effect method that will accept a license."
     def _side_effect(*args, **kwargs):
@@ -47,8 +64,7 @@ def accept_license(android_sdk_root_path):
     return _side_effect
 
 
-@pytest.mark.parametrize("host_os", ("ArbitraryNotWindows", "Windows"))
-def test_succeeds_immediately_in_happy_path(mock_command, host_os, tmp_path):
+def test_succeeds_immediately_in_happy_path(mock_command, tmp_path):
     "If verify is invoked on a path containing an Android SDK, it does nothing."
     # If `sdkmanager` exists and has the right permissions, and
     # `android-sdk-license` exists, verify() should
@@ -60,9 +76,9 @@ def test_succeeds_immediately_in_happy_path(mock_command, host_os, tmp_path):
 
     # Create `sdkmanager` and the license file.
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "tools" / "bin"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
     tools_bin.mkdir(parents=True, mode=0o755)
-    if host_os == "Windows":
+    if platform.system() == "Windows":
         (tools_bin / "sdkmanager.bat").touch()
     else:
         (tools_bin / "sdkmanager").touch(mode=0o755)
@@ -70,9 +86,6 @@ def test_succeeds_immediately_in_happy_path(mock_command, host_os, tmp_path):
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
 
-    # Configure `mock_command` to assume the `host_os` we parameterized with.
-    mock_command.host_os = host_os
-
     # Expect verify() to succeed
     sdk = AndroidSDK.verify(mock_command, jdk=MagicMock())
 
@@ -86,13 +99,116 @@ def test_succeeds_immediately_in_happy_path(mock_command, host_os, tmp_path):
     assert sdk.root_path == android_sdk_root_path
 
 
+def test_succeeds_immediately_in_happy_path_with_debug(mock_command, tmp_path):
+    "If debug is enabled, a verify call will display the installed packages."
+    # Increase the log level.
+    mock_command.logger.verbosity = 2
+
+    # If `sdkmanager` exists and has the right permissions, and
+    # `android-sdk-license` exists, verify() should
+    # succeed, create no subprocesses, make no requests, and return a
+    # SDK wrapper.
+
+    # On Windows, this requires `sdkmanager.bat`; on non-Windows, it requires
+    # `sdkmanager`.
+
+    # Create `sdkmanager` and the license file.
+    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
+    tools_bin.mkdir(parents=True, mode=0o755)
+    if platform.system() == "Windows":
+        sdk_manager = (tools_bin / "sdkmanager.bat")
+        sdk_manager.touch()
+    else:
+        sdk_manager = (tools_bin / "sdkmanager")
+        sdk_manager.touch(mode=0o755)
+
+    # Pre-accept the license
+    accept_license(android_sdk_root_path)()
+
+    # Expect verify() to succeed
+    jdk = MagicMock()
+    jdk.java_home = "/path/to/java"
+    sdk = AndroidSDK.verify(mock_command, jdk=jdk)
+
+    # No calls to download or unpack anything.
+    mock_command.download_url.assert_not_called()
+    mock_command.subprocess.check_output.assert_not_called()
+    mock_command.shutil.unpack_archive.assert_not_called()
+
+    # One call to run to dump the installed packages
+    mock_command.subprocess.run.assert_called_once_with(
+        [os.fsdecode(sdk_manager), "--list_installed"],
+        env={
+            "ANDROID_SDK_ROOT": os.fsdecode(android_sdk_root_path),
+            "JAVA_HOME": "/path/to/java"
+        },
+        check=True,
+    )
+
+    # The returned SDK has the expected root path.
+    assert sdk.root_path == android_sdk_root_path
+
+
 def test_user_provided_sdk(mock_command, tmp_path):
     "If the user specifies a valid ANDROID_SDK_ROOT, it is used"
+    # Increase the log level.
+    mock_command.logger.verbosity = 2
+
     # Create `sdkmanager` and the license file.
     existing_android_sdk_root_path = tmp_path / "other_sdk"
-    tools_bin = existing_android_sdk_root_path / "tools" / "bin"
+    tools_bin = existing_android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
     tools_bin.mkdir(parents=True, mode=0o755)
-    (tools_bin / "sdkmanager").touch(mode=0o755)
+    if platform.system() == "Windows":
+        sdk_manager = (tools_bin / "sdkmanager.bat")
+        sdk_manager.touch()
+    else:
+        sdk_manager = (tools_bin / "sdkmanager")
+        sdk_manager.touch(mode=0o755)
+
+    # Pre-accept the license
+    accept_license(existing_android_sdk_root_path)()
+
+    # Set the environment to specify ANDROID_SDK_ROOT
+    mock_command.os.environ = {
+        'ANDROID_SDK_ROOT': os.fsdecode(existing_android_sdk_root_path)
+    }
+
+    # Expect verify() to succeed
+    jdk = MagicMock()
+    jdk.java_home = "/path/to/java"
+    sdk = AndroidSDK.verify(mock_command, jdk=jdk)
+
+    # No calls to download or unpack anything.
+    mock_command.download_url.assert_not_called()
+    mock_command.subprocess.check_output.assert_not_called()
+    mock_command.shutil.unpack_archive.assert_not_called()
+
+    # One call to run to dump the installed packages
+    mock_command.subprocess.run.assert_called_once_with(
+        [os.fsdecode(sdk.sdkmanager_path), "--list_installed"],
+        env={
+            "ANDROID_SDK_ROOT": os.fsdecode(existing_android_sdk_root_path),
+            "JAVA_HOME": "/path/to/java"
+        },
+        check=True,
+    )
+    # The returned SDK has the expected root path.
+    assert sdk.root_path == existing_android_sdk_root_path
+
+
+def test_user_provided_sdk_with_debug(mock_command, tmp_path):
+    "If the has debug with a user-specified ANDROID_SDK_ROOT, the packages are listed."
+    # Create `sdkmanager` and the license file.
+    existing_android_sdk_root_path = tmp_path / "other_sdk"
+    tools_bin = existing_android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
+    tools_bin.mkdir(parents=True, mode=0o755)
+    if platform.system() == "Windows":
+        sdk_manager = (tools_bin / "sdkmanager.bat")
+        sdk_manager.touch()
+    else:
+        sdk_manager = (tools_bin / "sdkmanager")
+        sdk_manager.touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(existing_android_sdk_root_path)()
@@ -119,11 +235,16 @@ def test_invalid_user_provided_sdk(mock_command, tmp_path):
     "If the user specifies an invalid ANDROID_SDK_ROOT, it is ignored"
 
     # Create `sdkmanager` and the license file
-    # for the *briefcase* SDK.
+    # for the *briefcase* managed version of the SDK.
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "tools" / "bin"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
     tools_bin.mkdir(parents=True, mode=0o755)
-    (tools_bin / "sdkmanager").touch(mode=0o755)
+    if platform.system() == "Windows":
+        sdk_manager = (tools_bin / "sdkmanager.bat")
+        sdk_manager.touch()
+    else:
+        sdk_manager = (tools_bin / "sdkmanager")
+        sdk_manager.touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
@@ -146,28 +267,84 @@ def test_invalid_user_provided_sdk(mock_command, tmp_path):
     assert sdk.root_path == android_sdk_root_path
 
 
-@pytest.mark.parametrize("host_os", ("ArbitraryNotWindows", "Windows"))
-def test_download_sdk(mock_command, tmp_path, host_os):
+def test_download_sdk(mock_command, tmp_path):
     "If an SDK is not available, one will be downloaded"
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools"
+
+    # The download will produce a cached file.
+    cache_file = MagicMock()
+    mock_command.download_url.return_value = cache_file
+
+    # Calling unpack will create files
+    mock_command.shutil.unpack_archive.side_effect = mock_unpack
+
+    # Set up a side effect for accepting the license
+    mock_command.subprocess.run.side_effect = accept_license(android_sdk_root_path)
+
+    # Call `verify()`
+    sdk = AndroidSDK.verify(mock_command, jdk=MagicMock())
+
+    # Validate that the SDK was downloaded and unpacked
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_command._test_download_tag}-8092744_latest.zip"
+    )
+    mock_command.download_url.assert_called_once_with(
+        url=url,
+        download_path=mock_command.tools_path,
+    )
+
+    mock_command.shutil.unpack_archive.assert_called_once_with(
+        cache_file,
+        extract_dir=cmdline_tools_base_path
+    )
+
+    # The cached file will be deleted
+    cache_file.unlink.assert_called_once_with()
+
+    # The commandline tools path exists, in both "latest" and versioned form
+    assert sdk.cmdline_tools_path.exists()
+    assert sdk.cmdline_tools_version_path.exists()
+
+    # The versioned form is a marker file; the tools path is a live directory
+    assert sdk.cmdline_tools_path.is_dir()
+    assert sdk.cmdline_tools_version_path.is_file()
+
+    if platform.system() != 'Windows':
+        # On non-Windows, ensure the unpacked binary was made executable
+        assert os.access(cmdline_tools_base_path / 'latest' / 'bin' / 'sdkmanager', os.X_OK)
+
+    # The license has been accepted
+    assert (android_sdk_root_path / "licenses" / "android-sdk-license").exists()
+
+    # The returned SDK has the expected root path.
+    assert sdk.root_path == android_sdk_root_path
+
+
+def test_download_sdk_legacy_install(mock_command, tmp_path):
+    "If the legacy SDK tools are present, they will be deleted"
+    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools"
+
+    # Create files that mock the existence of the *old* SDK tools.
+    sdk_tools_base_path = android_sdk_root_path / "tools"
+    (sdk_tools_base_path / "bin").mkdir(parents=True)
+    (sdk_tools_base_path / "bin" / "sdkmanager").touch(mode=0o755)
+    (sdk_tools_base_path / "bin" / "avdmanager").touch(mode=0o755)
 
-    # Mock-out `host_os` so we only do our permission check on non-Windows.
-    mock_command.host_os = host_os
+    # Create some of the tools that have locations that overlap
+    # between legacy and new.
+    emulator_path = android_sdk_root_path / "emulator"
+    emulator_path.mkdir(parents=True)
+    (emulator_path / "emulator").touch(mode=0o755)
 
     # The download will produce a cached file.
-    # Consider to remove if block when we drop py3.7 support, only keep statements from else.
-    # MagicMock below py3.8 doesn't has __fspath__ attribute.
-    if sys.version_info < (3, 8):
-        cache_file = FsPathMock("/path/to/download.zip")
-    else:
-        cache_file = MagicMock()
-        cache_file.__fspath__.return_value = "/path/to/download.zip"
+    cache_file = MagicMock()
     mock_command.download_url.return_value = cache_file
 
-    # Create a file that would have been created by unpacking the archive
-    example_tool = android_sdk_root_path / "tools" / "bin" / "exampletool"
-    example_tool.parent.mkdir(parents=True)
-    example_tool.touch(0o644)
+    # Calling unpack will create files
+    mock_command.shutil.unpack_archive.side_effect = mock_unpack
 
     # Set up a side effect for accepting the license
     mock_command.subprocess.run.side_effect = accept_license(android_sdk_root_path)
@@ -176,23 +353,38 @@ def test_download_sdk(mock_command, tmp_path, host_os):
     sdk = AndroidSDK.verify(mock_command, jdk=MagicMock())
 
     # Validate that the SDK was downloaded and unpacked
-    url = f"https://dl.google.com/android/repository/sdk-tools-{host_os.lower()}-4333796.zip"
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_command._test_download_tag}-8092744_latest.zip"
+    )
     mock_command.download_url.assert_called_once_with(
         url=url,
         download_path=mock_command.tools_path,
     )
-    # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
+
     mock_command.shutil.unpack_archive.assert_called_once_with(
-        "/path/to/download.zip",
-        extract_dir=os.fsdecode(android_sdk_root_path)
+        cache_file,
+        extract_dir=cmdline_tools_base_path
     )
 
     # The cached file will be deleted
     cache_file.unlink.assert_called_once_with()
 
-    # On non-Windows, ensure the unpacked binary was made executable
-    if host_os != 'Windows':
-        assert os.access(example_tool, os.X_OK)
+    # The commandline tools path exists, in both "latest" and versioned form
+    assert sdk.cmdline_tools_path.exists()
+    assert sdk.cmdline_tools_version_path.exists()
+
+    # The versioned form is a marker file; the tools path is a live directory
+    assert sdk.cmdline_tools_path.is_dir()
+    assert sdk.cmdline_tools_version_path.is_file()
+
+    if platform.system() != 'Windows':
+        # On non-Windows, ensure the unpacked binary was made executable
+        assert os.access(cmdline_tools_base_path / 'latest' / 'bin' / 'sdkmanager', os.X_OK)
+
+    # The legacy SDK tools have been removed
+    assert not sdk_tools_base_path.exists()
+    assert not emulator_path.exists()
 
     # The license has been accepted
     assert (android_sdk_root_path / "licenses" / "android-sdk-license").exists()
@@ -212,28 +404,27 @@ def test_no_install(mock_command, tmp_path):
 
 
 @pytest.mark.skipif(
-    sys.platform == "win32", reason="executable permission doesn't make sense on Windows"
+    sys.platform == "win32",
+    reason="executable permission doesn't make sense on Windows"
 )
 def test_download_sdk_if_sdkmanager_not_executable(mock_command, tmp_path):
     """An SDK will be downloaded and unpackged if `tools/bin/sdkmanager` exists
     but does not have its permissions set properly."""
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools"
 
-    # Create non-executable `sdkmanager`.
-    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    (android_sdk_root_path / "tools" / "bin").mkdir(parents=True)
-    (android_sdk_root_path / "tools" / "bin" / "sdkmanager").touch(mode=0o644)
+    # Create pre-existing non-executable `sdkmanager`.
+    (cmdline_tools_base_path / "latest" / "bin").mkdir(parents=True)
+    (cmdline_tools_base_path / "latest" / "bin" / "sdkmanager").touch(mode=0o644)
+    (cmdline_tools_base_path / "8092744").touch()
 
     # The download will produce a cached file
-    # Consider to remove if block when we drop py3.7 support, only keep statements from else.
-    # MagicMock below py3.8 doesn't has __fspath__ attribute.
-    if sys.version_info < (3, 8):
-        cache_file = FsPathMock("/path/to/download.zip")
-    else:
-        cache_file = MagicMock()
-        cache_file.__fspath__.return_value = "/path/to/download.zip"
+    cache_file = MagicMock()
     mock_command.download_url.return_value = cache_file
 
+    # Calling unpack will create files
+    mock_command.shutil.unpack_archive.side_effect = mock_unpack
+
     # Set up a side effect for accepting the license
     mock_command.subprocess.run.side_effect = accept_license(android_sdk_root_path)
 
@@ -241,14 +432,18 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_command, tmp_path):
     sdk = AndroidSDK.verify(mock_command, jdk=MagicMock())
 
     # Validate that the SDK was downloaded and unpacked
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_command._test_download_tag}-8092744_latest.zip"
+    )
     mock_command.download_url.assert_called_once_with(
-        url="https://dl.google.com/android/repository/sdk-tools-unknown-4333796.zip",
+        url=url,
         download_path=mock_command.tools_path,
     )
-    # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
+
     mock_command.shutil.unpack_archive.assert_called_once_with(
-        "/path/to/download.zip",
-        extract_dir=os.fsdecode(android_sdk_root_path)
+        cache_file,
+        extract_dir=cmdline_tools_base_path
     )
 
     # The cached file will be deleted
@@ -269,8 +464,12 @@ def test_raises_networkfailure_on_connectionerror(mock_command):
         AndroidSDK.verify(mock_command, jdk=MagicMock())
 
     # The download was attempted
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_command._test_download_tag}-8092744_latest.zip"
+    )
     mock_command.download_url.assert_called_once_with(
-        url="https://dl.google.com/android/repository/sdk-tools-unknown-4333796.zip",
+        url=url,
         download_path=mock_command.tools_path,
     )
     # But no unpack occurred
@@ -281,14 +480,7 @@ def test_detects_bad_zipfile(mock_command, tmp_path):
     "If the ZIP file is corrupted, an error is raised."
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
 
-    # The download will produce a cached file
-    # Consider to remove if block when we drop py3.7 support, only keep statements from else.
-    # MagicMock below py3.8 doesn't has __fspath__ attribute.
-    if sys.version_info < (3, 8):
-        cache_file = FsPathMock("/path/to/download.zip")
-    else:
-        cache_file = MagicMock()
-        cache_file.__fspath__.return_value = "/path/to/download.zip"
+    cache_file = MagicMock()
     mock_command.download_url.return_value = cache_file
 
     # But the unpack will fail.
@@ -298,12 +490,15 @@ def test_detects_bad_zipfile(mock_command, tmp_path):
         AndroidSDK.verify(mock_command, jdk=MagicMock())
 
     # The download attempt was made.
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_command._test_download_tag}-8092744_latest.zip"
+    )
     mock_command.download_url.assert_called_once_with(
-        url="https://dl.google.com/android/repository/sdk-tools-unknown-4333796.zip",
+        url=url,
         download_path=mock_command.tools_path,
     )
-    # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_command.shutil.unpack_archive.assert_called_once_with(
-        "/path/to/download.zip",
-        extract_dir=os.fsdecode(android_sdk_root_path)
+        cache_file,
+        extract_dir=android_sdk_root_path / "cmdline-tools"
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
index 69bc2aec..45e6a0a7 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
@@ -1,5 +1,6 @@
 import os
 import subprocess
+from pathlib import Path
 
 import pytest
 
@@ -18,23 +19,81 @@ def test_succeeds_immediately_if_emulator_installed(mock_sdk):
     mock_sdk.command.requests.get.assert_not_called()
 
 
-def test_installs_android_emulator(mock_sdk):
+def test_succeeds_immediately_if_emulator_installed_with_debug(mock_sdk, tmp_path):
+    """If the emulator exist and debug is turned on, the list of packages is displayed"""
+    # Turn up logging to debug levels
+    mock_sdk.command.logger.verbosity = 2
+
+    # Create `emulator` within `root_path`.
+    (mock_sdk.root_path / "emulator").mkdir(parents=True)
+
+    mock_sdk.verify_emulator()
+
+    # No extra calls made
+    mock_sdk.command.requests.get.assert_not_called()
+
+    # But a call to run is made to dump the installed packages
+    mock_sdk.command.subprocess.run.assert_called_once_with(
+        [os.fsdecode(mock_sdk.sdkmanager_path), "--list_installed"],
+        env={
+            "ANDROID_SDK_ROOT": os.fsdecode(tmp_path / 'sdk'),
+            "JAVA_HOME": os.fsdecode(Path("/path/to/jdk")),
+        },
+        check=True,
+    )
+
+
+@pytest.mark.parametrize(
+    "host_os, host_arch, emulator_abi",
+    [
+        ("Darwin", "x86_64", "x86_64"),
+        ("Darwin", "arm64", "arm64-v8a"),
+        ("Windows", "x86_64", "x86_64"),
+        ("Linux", "x86_64", "x86_64"),
+    ]
+)
+def test_installs_android_emulator(mock_sdk, host_os, host_arch, emulator_abi):
     "The emulator tools will be installed if needed"
+    # Mock the hardware and OS
+    mock_sdk.command.host_os = host_os
+    mock_sdk.command.host_arch = host_arch
+
     mock_sdk.verify_emulator()
 
     mock_sdk.command.subprocess.run.assert_called_once_with(
         [
             os.fsdecode(mock_sdk.sdkmanager_path),
-            "platforms;android-28",
-            "system-images;android-28;default;x86",
-            "emulator",
             "platform-tools",
+            "emulator",
+            f"system-images;android-31;default;{emulator_abi}",
         ],
         env=mock_sdk.env,
         check=True,
     )
 
 
+@pytest.mark.parametrize(
+    "host_os, host_arch",
+    [
+        ("Windows", "arm64"),
+        ("Linux", "arm64"),
+    ]
+)
+def test_unsupported_emulator_platform(mock_sdk, host_os, host_arch):
+    "If the platform isn't supported by the Android emulator, an error is raised"
+
+    mock_sdk.command.host_os = host_os
+    mock_sdk.command.host_arch = host_arch
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=f"The Android emulator does not currently support {host_os} {host_arch} hardware"
+    ):
+        mock_sdk.verify_emulator()
+
+    mock_sdk.command.subprocess.run.assert_not_called()
+
+
 def test_install_problems_are_reported(mock_sdk):
     "If the sdkmanager fails to properly install the Android emulator, an exception is raised."
     # Configure `subprocess` module to crash as though it were a sad sdkmanager.
diff --git a/tests/integrations/android_sdk/conftest.py b/tests/integrations/android_sdk/conftest.py
index db4071cd..05d12f5c 100644
--- a/tests/integrations/android_sdk/conftest.py
+++ b/tests/integrations/android_sdk/conftest.py
@@ -3,7 +3,9 @@ from unittest.mock import MagicMock
 
 import pytest
 
+from briefcase.console import Log
 from briefcase.integrations.android_sdk import AndroidSDK
+
 from tests.utils import DummyConsole
 
 
@@ -13,6 +15,11 @@ def mock_sdk(tmp_path):
     command.home_path = tmp_path
     command.subprocess = MagicMock()
     command.input = DummyConsole()
+    command.logger = Log(verbosity=1)
+
+    # For default test purposes, assume we're on macOS x86_64
+    command.host_os = 'Darwin'
+    command.host_arch = 'x86_64'
 
     # Mock an empty environment
     command.os.environ = {}