Repository: beeware/briefcase
Pull request #1397 (2023-08-01 17:48:45)

Bump Android Command-line Tools from 6.0 to 9.0
## Changes
- The version of the Android Command-line Tools is bumped from 6.0 to 9.0
- Previously, Android's Command-line Tools were installed into `cmdline-tools/latest` by Briefcase.
- However, the SDK supports a versioned layout for installed packages. To take advantage of this, the Command-line Tools are now installed in to `cmdline-tools/9.0` in the Briefcase data directory.
- This also ensures that user SDKs are providing the same version of Command-line Tools as Briefcase installs.
- If an externally managed SDK doesn't have Command-line Tools version 9.0, it will be installed in to the SDK using the `latest` version of Command-line Tools.
  - If `latest` version isn't installed, the user is warned and the Briefcase-managed SDK is used.
  - This is especially necessary for CI because while GitHub has version 9.0 installed, it is installed as `latest`

## Notes
- Initial discussion: https://github.com/beeware/briefcase/pull/1360#discussion_r1278764413

<!--- Describe your changes in detail -->
<!--- What problem does this change solve? -->
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [X] All new features have been tested
- [X] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct

rmartin16: During a cutover from a previous version of Briefcase to this one:

For Briefcase-managed SDK:
- Briefcase will _appear_ to do a fresh install of the Briefcase-managed Android SDK
- However, it's just downloads the `9477386` cmdline-tools zip and unpacks it to `android_sdk/cmdline-tools/9.0` while leaving the rest of the SDK in place

For an externally managed SDK:
- If a version of `cmdline-tools` is installed as `latest`, then it will be used to install `cmdline-tools;9.0`
- If a version of `cmdline-tools` is *not* installed as `latest`, a warning is shown and Briefcase's SDK is used

I think this approach provides a good strategy to creating better reproducibility with SDK use while also providing a repeatable method for bumping the Command-line Tools version. Please let me know if there's an aspect I'm failing to consider.
rmartin16: Great feedback; thanks.

I had some similar thoughts once I was going through some of the workflows.

> wondering if there's anything we can do around the cosmetics of the "existing user upgrade"

There's currently a divergence for how this "upgrade" is handled for a Briefcase-managed SDK vs a User-managed SDK. Instead, if I just use the same method to install a specific version of the Command-line Tools for the Briefcase-managed SDK, then it won't look like fresh install and create that confusion. It will also leave the current install codepath to only run for an actually fresh install.

> having a "latest" folder that clearly isn't the latest any more seems like bad housekeeping

> My only hesitation on this is the churn for any user who is moving between old and new versions of briefcase.

Yeah; that's true...but even if someone is switching that much, the download isn't _that_ large....and if they really want to, they could set up separate SDK installs for their purposes.

So, I'm thinking I'll just delete the `latest` directory if an "upgrade" is happening for the Briefcase-managed SDK.
rmartin16: Realized there's a bit a bootstrapping problem with my idea. If I'm planning to delete the `latest` directory for Briefcase-managed SDKs, I can't very well depend on it in the future for installing the version that's needed.

There is another option I was considering. In both these cases, Briefcase-managed and user-managed, we're depending on some hopefully existing state of the SDK to perform the upgrade. Alternatively, we could just download the `cmdline-tools` ZIP file, unpack it, and instead of installing the unpacked files in to the SDK location, just use the unpacked `sdkmanager` to perform the install in to the SDK location using the `--sdk_root` option. This is definitely a bit duplicative....but it also defers the actual install to `sdkmanager` itself and will work regardless of the state of the SDK.
rmartin16: Here's what I think will help simplify this and create the best experience:

- `ANDROID_HOME` is valued
  - If `ANDROID_HOME` doesn't "look like" an Android SDK root directory, inform the user the SDK file path isn't valid and proceed with Briefcase-managed SDK

- Validate the SDK
  - The code flow for user-managed and Briefcase-managed SDKs converges at this point
  - If `<SDK_ROOT>/cmdline-tools/<SDK_MGR_VER>/bin/sdkmanager` exists, use the SDK
  - If the `<SDK_ROOT>` doesn't "look like" an Android SDK (or doesn't exist), perform a new install
    - This only applies to the Briefcase-managed SDK since the user SDK already passed this validation
  - Finally, install Command-line Tool version <SDK_MGR_VER> in to either the Briefcase or User SDK

"Looking like an Android SDK" is defined as containing at least one of the `cmdline-tools`, `build-tools`, or `platform-tools` directories.

Taking a closer look at what Android Studio does (as well as some of its source), it definitely appears to just download the `9477386` zip file and copy it in to the SDK at `cmdline-tools/9.0`.
```
Preparing "Install Android SDK Command-line Tools v.9.0".
Downloading https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
"Install Android SDK Command-line Tools v.9.0" ready.
Installing Android SDK Command-line Tools in /opt/sdks/Android/sdk/cmdline-tools/9.0
"Install Android SDK Command-line Tools v.9.0" complete.
"Install Android SDK Command-line Tools v.9.0" finished.
```

Given this, I feel mostly comfortable with doing this procedure even for User-managed SDKs as well as the Briefcase-managed SDK.
mhsmith: That all sounds reasonable. But what does "perform a new install" mean beyond just installing the command-line tools?
freakboy3742: > > Given this, I feel mostly comfortable with doing this procedure even for User-managed SDKs as well as the Briefcase-managed SDK.
> 
> Unfortunately, though, this isn't proving entirely true. If you just download the ZIP file and unpack it in to an SDK that managed by Android Studio...Android Studio won't actually register the install in its UI. In the very least, when you run `sdkmanager "cmdline-tools;9.0"`, it also creates a `package.xml` file that Android Studio ostensibly uses to actually register that a package has been installed.

This makes at least some sense; if the tools are downloaded in to `cmdline-tools/9.0`, how does Studio know which folder should be on the path (or should be used explicitly to reference `sdkmanager`)? When it downloaded into `latest`, the path to sdkmanager was predictable; `9.0` isn't, without additional metadata.
 
> Therefore, I don't think it's really safe to modify an externally-managed SDK without using `sdkmanager`.
>
> However, we're back to the bootstrapping problem: if the `sdkmanager` is needed to install the `sdkmanager`, how can Briefcase reliably find an `sdkmanager`?
> 
> I'm thinking now it is safest to simply _always_ download and unpack the needed version of `cmdline-tools` in to a Briefcase-managed SDK. That way, it can be used to reliably provide the `sdkmanager` to install the required version of `cmdline-tools` in to an externally-managed SDK.....or just facilitate using Briefcase-managed SDK like normal.

That definitely make sense for the Briefcase-managed case; however, I'm not sure how it helps with the externally managed case. Unless Studio consistently produces a `latest` directory (which it used to do AFAIK, but it sounds like it might have stopped), we're going to need to discover the version that is currently installed. 

I guess the good news is that the directory structure is just version numbers, so it's not *too* hard to discover the highest version number - but I'm not sure I'm wild about that as an approach.
rmartin16: > > > Given this, I feel mostly comfortable with doing this procedure even for User-managed SDKs as well as the Briefcase-managed SDK.
> > 
> > 
> > Unfortunately, though, this isn't proving entirely true. If you just download the ZIP file and unpack it in to an SDK that managed by Android Studio...Android Studio won't actually register the install in its UI. In the very least, when you run `sdkmanager "cmdline-tools;9.0"`, it also creates a `package.xml` file that Android Studio ostensibly uses to actually register that a package has been installed.
> 
> This makes at least some sense; if the tools are downloaded in to `cmdline-tools/9.0`, how does Studio know which folder should be on the path (or should be used explicitly to reference `sdkmanager`)? When it downloaded into `latest`, the path to sdkmanager was predictable; `9.0` isn't, without additional metadata.

If one downloads and installs Android Studio today, it doesn't install _any_ version of `cmdline-tools` in to the SDK it creates. This was my experience anyway.

> > Therefore, I don't think it's really safe to modify an externally-managed SDK without using `sdkmanager`.
> > However, we're back to the bootstrapping problem: if the `sdkmanager` is needed to install the `sdkmanager`, how can Briefcase reliably find an `sdkmanager`?
> > I'm thinking now it is safest to simply _always_ download and unpack the needed version of `cmdline-tools` in to a Briefcase-managed SDK. That way, it can be used to reliably provide the `sdkmanager` to install the required version of `cmdline-tools` in to an externally-managed SDK.....or just facilitate using Briefcase-managed SDK like normal.
> 
> That definitely make sense for the Briefcase-managed case; however, I'm not sure how it helps with the externally managed case. Unless Studio consistently produces a `latest` directory (which it used to do AFAIK, but it sounds like it might have stopped), we're going to need to discover the version that is currently installed.

I don't think I'm following...I'm simply proposing that Briefcase keeps a copy of the `cmdline-tools` that is manages regardless of whether an externally-managed SDK is specified by the user.

Does that make sense?

If Briefcase has a reliable copy of `cmdline-tools` that it manages, then it can safely interact with externally-managed SDKs. Although, presently, it would only be used to install a copy of `cmdline-tools` in to the externally-managed SDK.

I'm proposing this for 3 goals:
1) Solve the bootstrapping problem of needing an `sdkmanager` without knowing where one will be (or if one even exists)
2) Use a reliable version of `sdkmanager` across all Briefcase installs
3) Prevent requiring users manually install a specific version of `cmdline-tools` to use it with Briefcase
rmartin16: Oh...in case I haven't brought this up...

The `sdkmanager` supports interacting with arbitrary SDKs installed elsewhere in the filesystem. 

```
sdkmanager --help
...
    --sdk_root=<sdkRootPath>: Use the specified SDK root instead of the SDK
                              containing this tool
...
```
freakboy3742: > If one downloads and installs Android Studio today, it doesn't install _any_ version of `cmdline-tools` in to the SDK it creates. This was my experience anyway.

How does an Android SDK user get a command line sdkmanager? It might not be installed by default, but my understanding was that it was selectable from the in-Studio GUI for package management. 

> I don't think I'm following...I'm simply proposing that Briefcase keeps a copy of the `cmdline-tools` that is manages regardless of whether an externally-managed SDK is specified by the user.
> 
> Does that make sense?
> 
> If Briefcase has a reliable copy of `cmdline-tools` that it manages, then it can safely interact with externally-managed SDKs. Although, presently, it would only be used to install a copy of `cmdline-tools` in to the externally-managed SDK.

Is it going to be safe for a Briefcase-managed SDK instance to alter a non-briefcase managed SDK install? It seems like asking for trouble to me...

For my money, supporting `ANDROID_HOME` *at all* is an optimisation for people who know what they're doing. We should certainly make an effort to make the user-managed experience nice, but I think it's also reasonable to expect a user who wants to use a user-managed SDK install to... you know... manage their SDK install :-)

To that end, I'd suggest the following behavior:
1. Briefcase-managed install
   1. If a `9.0` sdk manager exists, use it
   2. If a `latest` directory and `8092744` marker file exist, do an upgrade to 9.0, then delete the old versions
   3. If no SDK exists, download the 9.0 zipfile.
   4. Modify the code (as you've done in the PR) to reference a specific version of the cmdline-tools.
2. User-managed install
   1. If a `9.0` sdkmanager exists, use it
   2. If a `latest` sdkmanager exists, use it to install 9.0. Notify the user that the `latest` folder might need to be cleaned   up because it's not "latest" any more.
   3. Otherwise, tell the user that they have a user-managed SDK that doesn't have sdkmanager, so they should either install `cmdline-tools;9.0` with Android Studio, or unset `ANDROID_HOME` to let briefcase manage it.

It might be possible to extend (2.ii) to include a couple of other likely candidates (e.g., looking for `cmdline-tools;6.0`, `cmdline-tools;7.0` etc); but unless there's a reliable way for us to get the `cmdline-tools` installed into a user-managed SDK instance, I think it's better to raise an error than risk messing up their install.
rmartin16: > How does an Android SDK user get a command line sdkmanager? It might not be installed by default, but my understanding was that it was selectable from the in-Studio GUI for package management.

It is an optional selection within the Studio's Android SDK management UI.

> Is it going to be safe for a Briefcase-managed SDK instance to alter a non-briefcase managed SDK install? It seems like asking for trouble to me...

I don't really see any reason to think this would be a problem; `sdkmanager` has an explicit option to interact with arbitrary SDK installations.

> To that end, I'd suggest the following behavior:

This is basically what my previous commit is; I'm fine with this approach.

Furthermore, though, I think we should get away from unpacking the cmdline-tools zip file in to the Briefcase-managed SDK. It is quite clear to me at this point this isn't really considered a valid installation by the Command-line Tools themselves. For instance, if you run `sdkmanager --list-installed` after doing this, it doesn't include the just unpacked `cmdline-tools`. However, if you instead just use the unpacked `sdkmanager` to install `cmdline-tools;9.0` in to the Briefcase-managed SDK, it is properly seen as a package installed in the SDK. This is also why if you just copy the contents of the zip file in to Studio's managed copy of the SDK, it doesn't register it as installed either.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0ed18e0b..dae69d70 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
       - id: isort
         additional_dependencies: [toml]
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.10.1
+    rev: v3.9.0
     hooks:
       - id: pyupgrade
         args: [--py38-plus]
diff --git a/changes/1397.feature.rst b/changes/1397.feature.rst
new file mode 100644
index 00000000..fa95440f
--- /dev/null
+++ b/changes/1397.feature.rst
@@ -0,0 +1,1 @@
+The Android SDK's Command-Line Tools is now version 9.0. If an externally-managed Android SDK is being used, it must provide this version of Command-Line Tools. Use the SDK Manager in Android Studio to ensure it is installed.
diff --git a/changes/1400.misc.rst b/changes/1400.misc.rst
deleted file mode 100644
index 9eb46067..00000000
--- a/changes/1400.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version.
diff --git a/changes/1401.misc.rst b/changes/1401.misc.rst
deleted file mode 100644
index 38404a71..00000000
--- a/changes/1401.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated sphinx from 7.1.1 to 7.1.2.
diff --git a/docs/reference/platforms/android.rst b/docs/reference/platforms/android.rst
index 17ac5259..d08ed8c8 100644
--- a/docs/reference/platforms/android.rst
+++ b/docs/reference/platforms/android.rst
@@ -9,7 +9,9 @@ Gradle requires an install of the Android SDK and a Java 17 JDK.
 If you have an existing install of the Android SDK, it will be used by Briefcase
 if the ``ANDROID_HOME`` environment variable is set. If ``ANDROID_HOME`` is not
 present in the environment, Briefcase will honor the deprecated
-``ANDROID_SDK_ROOT`` environment variable.
+``ANDROID_SDK_ROOT`` environment variable. Additionally, an existing SDK install
+must have version 9.0 of Command-line Tools installed; this version can be
+installed in the SDK Manager in Android Studio.
 
 If you have an existing install of a Java 17 JDK, it will be used by Briefcase
 if the ``JAVA_HOME`` environment variable is set. On macOS, if ``JAVA_HOME`` is
diff --git a/setup.cfg b/setup.cfg
index f72e164f..a01cdc2e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -98,7 +98,7 @@ dev =
 docs =
     furo == 2023.7.26
     pyenchant == 3.2.2
-    sphinx == 7.1.2
+    sphinx == 7.1.1
     sphinx_tabs == 3.4.1
     sphinx-autobuild == 2021.3.14
     sphinx-copybutton == 0.5.2
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 14742c16..12e4d541 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -47,6 +47,11 @@ class AndroidSDK(ManagedTool):
     name = "android_sdk"
     full_name = "Android SDK"
 
+    # Latest version for Command-Line Tools download as of August 2023
+    # **Be sure the android.rst docs stay in sync with version updates here**
+    SDK_MANAGER_DOWNLOAD_VER = "9477386"
+    SDK_MANAGER_VER = "9.0"
+
     def __init__(self, tools: ToolCache, root_path: Path):
         super().__init__(tools=tools)
         self.dot_android_path = self.tools.home_path / ".android"
@@ -57,39 +62,30 @@ class AndroidSDK(ManagedTool):
 
     @property
     def cmdline_tools_url(self) -> str:
-        """The Android SDK Command-Line Tools URL appropriate to the current operating
-        system."""
+        """The Android SDK Command-Line Tools URL appropriate to the current OS."""
         platform_name = self.tools.host_os.lower()
         if self.tools.host_os.lower() == "darwin":
             platform_name = "mac"
         elif self.tools.host_os.lower() == "windows":  # pragma: no branch
             platform_name = "win"
 
-        return f"https://dl.google.com/android/repository/commandlinetools-{platform_name}-{self.cmdline_tools_version}_latest.zip"  # noqa: E501
+        return (
+            f"https://dl.google.com/android/repository/"
+            f"commandlinetools-{platform_name}-{self.SDK_MANAGER_DOWNLOAD_VER}_latest.zip"
+        )
 
     @property
     def cmdline_tools_path(self) -> Path:
-        return self.root_path / "cmdline-tools" / "latest"
-
-    @property
-    def cmdline_tools_version(self) -> str:
-        # 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 a 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"
+        """Version-specific Command-line tools install root directory."""
+        return self.root_path / "cmdline-tools" / self.SDK_MANAGER_VER
 
     @property
-    def cmdline_tools_version_path(self) -> Path:
-        return self.root_path / "cmdline-tools" / self.cmdline_tools_version
+    def sdkmanager_filename(self) -> str:
+        return "sdkmanager.bat" if self.tools.host_os == "Windows" else "sdkmanager"
 
     @property
     def sdkmanager_path(self) -> Path:
-        sdkmanager = (
-            "sdkmanager.bat" if self.tools.host_os == "Windows" else "sdkmanager"
-        )
-        return self.cmdline_tools_path / "bin" / sdkmanager
+        return self.cmdline_tools_path / "bin" / self.sdkmanager_filename
 
     @property
     def adb_path(self) -> Path:
@@ -226,15 +222,16 @@ class AndroidSDK(ManagedTool):
         JDK.verify(tools=tools, install=install)
 
         sdk = None
-        sdk_root, sdk_env_source = cls.sdk_path_from_env(tools=tools)
 
-        if sdk_root:
+        # Verify externally-managed Android SDK
+        sdk_root_env, sdk_source_env = cls.sdk_path_from_env(tools=tools)
+        if sdk_root_env:
             tools.logger.debug("Evaluating ANDROID_HOME...", prefix=cls.full_name)
-            tools.logger.debug(f"{sdk_env_source}={sdk_root}")
-            sdk = AndroidSDK(tools=tools, root_path=Path(sdk_root))
+            tools.logger.debug(f"{sdk_source_env}={sdk_root_env}")
+            sdk = AndroidSDK(tools=tools, root_path=Path(sdk_root_env))
 
             if sdk.exists():
-                if sdk_env_source == "ANDROID_SDK_ROOT":
+                if sdk_source_env == "ANDROID_SDK_ROOT":
                     tools.logger.warning(
                         """
 *************************************************************************
@@ -253,26 +250,49 @@ class AndroidSDK(ManagedTool):
 *************************************************************************
 """
                     )
-                sdk.verify_license()
+            elif sdk.cmdline_tools_path.parent.exists():
+                # a cmdline-tools directory exists but the required version isn't installed.
+                # try to install the required version using the 'latest' version.
+                if not sdk.install_cmdline_tools():
+                    sdk = None
+                    tools.logger.warning(
+                        f"""
+*************************************************************************
+** WARNING: Incompatible Command-Line Tools Version                    **
+*************************************************************************
+
+    The Android SDK specified by {sdk_source_env} at:
+
+    {sdk_root_env}
+
+    does not contain Command-Line Tools version {cls.SDK_MANAGER_VER}. Briefcase requires
+    this version to be installed to use an external Android SDK.
+
+    Use Android Studio's SDK Manager to install Command-Line Tools {cls.SDK_MANAGER_VER}.
+
+    Briefcase will proceed using its own SDK instance.
+
+*************************************************************************
+"""
+                    )
             else:
-                sdk = None
                 tools.logger.warning(
                     f"""
 *************************************************************************
-** {f"WARNING: {sdk_env_source} does not point to an Android SDK":67} **
+** {f"WARNING: {sdk_source_env} does not point to an Android SDK":67} **
 *************************************************************************
 
-    The location pointed to by the {sdk_env_source} environment
+    The location pointed to by the {sdk_source_env} environment
     variable:
 
-    {sdk_root}
+    {sdk_root_env}
 
     doesn't appear to contain an Android SDK.
 
-    If {sdk_env_source} is an Android SDK, ensure it is the root directory
+    If {sdk_source_env} is an Android SDK, ensure it is the root directory
     of the Android SDK instance such that
 
-    ${sdk_env_source}/cmdline-tools/latest/bin/sdkmanager
+    ${sdk_source_env}{os.sep}{sdk.sdkmanager_path.relative_to(sdk.root_path)}
 
     is a valid filepath.
 
@@ -281,53 +301,35 @@ class AndroidSDK(ManagedTool):
 *************************************************************************
 """
                 )
+                sdk = None
 
+        # Verify Briefcase-managed Android SDK
         if sdk is None:
-            # Build an SDK wrapper for the Briefcase SDK instance.
             sdk_root_path = tools.base_path / "android_sdk"
             sdk = AndroidSDK(tools=tools, root_path=sdk_root_path)
 
-            if sdk.exists():
-                # NOTE: For now, all known versions of the cmdline-tools are compatible.
-                # If/when that ever changes, do a verification check here.
-                sdk.verify_license()
-            else:
-                # The legacy SDK Tools exist. Delete them.
-                if (sdk_root_path / "tools").exists():
-                    tools.logger.warning(
-                        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.
+            if not sdk.exists():
+                if not install:
+                    raise MissingToolError("Android SDK")
 
-*************************************************************************
-"""
-                    )
-                    tools.shutil.rmtree(sdk_root_path)
+                sdk.delete_legacy_sdk_tools()
 
-                if install:
+                if sdk.cmdline_tools_path.parent.exists():
+                    tools.logger.info("Upgrading Android SDK...", prefix=cls.name)
+                else:
                     tools.logger.info(
                         "The Android SDK was not found; downloading and installing...",
                         prefix=cls.name,
                     )
                     tools.logger.info(
-                        "To use an existing Android SDK instance, "
-                        "specify its root directory path in the ANDROID_HOME environment variable."
+                        "To use an existing Android SDK instance, specify its root "
+                        "directory path in the ANDROID_HOME environment variable."
                     )
                     tools.logger.info()
-                    sdk.install()
-                else:
-                    raise MissingToolError("Android SDK")
+                sdk.install()
+
+        # Licences must be accepted to use the SDK
+        sdk.verify_license()
 
         tools.logger.debug(f"Using Android SDK at {sdk.root_path}")
         tools.android_sdk = sdk
@@ -353,7 +355,6 @@ class AndroidSDK(ManagedTool):
 
     def uninstall(self):
         """The Android SDK is upgraded in-place instead of being reinstalled."""
-        pass
 
     def install(self):
         """Download and install the Android SDK."""
@@ -364,18 +365,18 @@ class AndroidSDK(ManagedTool):
         )
 
         # The cmdline-tools package *must* be installed as:
-        #     <sdk_path>/cmdline-tools/latest
+        #     <sdk_path>/cmdline-tools/<cmdline-tools version>
         #
         # 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.
+        #  3. Move <sdk_path>/cmdline-tools/cmdline-tools to <sdk_path>/cmdline-tools/<cmdline-tools version>
 
-        with self.tools.input.wait_bar("Installing Android SDK Command-Line Tools..."):
+        with self.tools.input.wait_bar(
+            f"Installing Android SDK Command-Line Tools {self.SDK_MANAGER_VER}..."
+        ):
             self.cmdline_tools_path.parent.mkdir(parents=True, exist_ok=True)
             try:
                 self.tools.shutil.unpack_archive(
@@ -391,18 +392,14 @@ Delete {cmdline_tools_zip_path} and run briefcase again.
 """
                 ) from e
 
-            # If there's an existing version of the cmdline tools (or the version marker), delete them.
+            # If there's an existing version of the cmdline tools, delete them.
             if self.cmdline_tools_path.exists():
                 self.tools.shutil.rmtree(self.cmdline_tools_path)
-            if self.cmdline_tools_version_path.exists():
-                self.tools.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.
             cmdline_tools_zip_path.unlink()
@@ -416,8 +413,8 @@ Delete {cmdline_tools_zip_path} and run briefcase again.
                     if not self.tools.os.access(binpath, self.tools.os.X_OK):
                         binpath.chmod(0o755)
 
-        # Licences must be accepted.
-        self.verify_license()
+        with self.tools.input.wait_bar("Removing older Android SDK packages..."):
+            self.cleanup_old_installs()
 
     def upgrade(self):
         """Upgrade the Android SDK."""
@@ -440,6 +437,92 @@ its output for errors.
 """
             ) from e
 
+    def install_cmdline_tools(self) -> bool:
+        """Attempt to use 'latest' cmdline-tools to install the currently required
+        version of the Command-Line Tools.
+
+        The Briefcase-managed SDK should always have the required version of cmdline-
+        tools installed; however, user-provided SDKs may not have it.
+
+        :returns: True if successfully installed; False otherwise
+        """
+        self.tools.logger.info(
+            f"Installing Android Command-Line Tools {self.SDK_MANAGER_VER}...",
+            prefix=self.full_name,
+        )
+        self.tools.logger.info(f"Using Android SDK at {self.root_path}")
+        latest_sdkmanager_path = (
+            self.root_path
+            / "cmdline-tools"
+            / "latest"
+            / "bin"
+            / self.sdkmanager_filename
+        )
+        try:
+            self.tools.subprocess.run(
+                [
+                    latest_sdkmanager_path,
+                    f"cmdline-tools;{self.SDK_MANAGER_VER}",
+                ],
+                check=True,
+                stream_output=False,
+            )
+        except (OSError, subprocess.CalledProcessError) as e:
+            self.tools.logger.debug(str(e))
+            self.tools.logger.warning(
+                f"Failed to install cmdline-tools;{self.SDK_MANAGER_VER}"
+            )
+            return False
+        return True
+
+    def delete_legacy_sdk_tools(self):
+        """Delete any legacy Android SDK tools that are installed.
+
+        If no versions of the Command-Line Tools are installed but the 'tools' directory
+        exists, the legacy SDK Tools are probably installed. Since they have been
+        deprecated by more recent releases of SDK Manager, delete them and perform a
+        fresh install.
+
+        The Android SDK Tools were deprecated in Sept 2017.
+        """
+        if (
+            not self.cmdline_tools_path.parent.exists()
+            and (self.root_path / "tools").exists()
+        ):
+            self.tools.logger.warning(
+                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 {self.avd_path} matching the emulator name.
+
+*************************************************************************
+"""
+            )
+            self.tools.shutil.rmtree(self.root_path)
+
+    def cleanup_old_installs(self):
+        """Remove old versions of Android SDK packages and version markers.
+
+        When the Android SDK is upgraded, old versions of packages should be removed to
+        keep the SDK tidy. This is namely the Command-line Tools that are used to manage
+        the SDK and AVDs. Additionally, previous version of Briefcase created a version
+        marker file that needs to be deleted.
+        """
+        if (ver_file := self.cmdline_tools_path.parent / "8092744").is_file():
+            self.tools.os.unlink(ver_file)
+        if (latest := self.cmdline_tools_path.parent / "latest").is_dir():
+            self.tools.shutil.rmtree(latest)
+
     def list_packages(self):
         """In debug output, list the packages currently managed by the SDK."""
         try:


Test patch:
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_properties.py b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
index 9aae960f..249c077f 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_properties.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
@@ -5,6 +5,8 @@ import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
 
+from ..conftest import SDK_MGR_DL_VER, SDK_MGR_VER
+
 
 @pytest.mark.parametrize(
     "host_os, name",
@@ -20,7 +22,7 @@ def test_cmdline_tools_url(mock_tools, android_sdk, host_os, name):
     mock_tools.host_os = host_os
 
     assert android_sdk.cmdline_tools_url == (
-        f"https://dl.google.com/android/repository/commandlinetools-{name}-8092744_latest.zip"
+        f"https://dl.google.com/android/repository/commandlinetools-{name}-{SDK_MGR_DL_VER}_latest.zip"
     )
 
 
@@ -35,7 +37,7 @@ def test_sdkmanager_path(mock_tools, android_sdk, host_os, sdkmanager_name):
     mock_tools.host_os = host_os
 
     assert android_sdk.sdkmanager_path == (
-        android_sdk.root_path / "cmdline-tools" / "latest" / "bin" / sdkmanager_name
+        android_sdk.root_path / "cmdline-tools" / SDK_MGR_VER / "bin" / sdkmanager_name
     )
 
 
@@ -62,7 +64,7 @@ def test_avdmanager_path(mock_tools, android_sdk, host_os, avdmanager_name):
     mock_tools.host_os = host_os
 
     assert android_sdk.avdmanager_path == (
-        android_sdk.root_path / "cmdline-tools" / "latest" / "bin" / avdmanager_name
+        android_sdk.root_path / "cmdline-tools" / SDK_MGR_VER / "bin" / avdmanager_name
     )
 
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index 479a2f8b..11d6768b 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -14,6 +14,12 @@ from briefcase.exceptions import (
 from briefcase.integrations.android_sdk import AndroidSDK
 from briefcase.integrations.base import ToolCache
 
+from ..conftest import SDK_MGR_DL_VER, SDK_MGR_VER
+
+SDKMANAGER_FILENAME = (
+    "sdkmanager.bat" if platform.system() == "Windows" else "sdkmanager"
+)
+
 
 @pytest.fixture
 def mock_tools(mock_tools) -> ToolCache:
@@ -22,6 +28,7 @@ def mock_tools(mock_tools) -> ToolCache:
     mock_tools.os.fsdecode = os.fsdecode
     mock_tools.os.access = os.access
     mock_tools.os.X_OK = os.X_OK
+    mock_tools.os.unlink = os.unlink
 
     # Identify the host platform
     mock_tools._test_download_tag = {
@@ -84,19 +91,11 @@ def test_succeeds_immediately_in_happy_path(mock_tools, tmp_path):
     # exists, verify() should succeed, create no subprocesses, make no requests, and
     # return an 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 = android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
@@ -121,14 +120,9 @@ def test_user_provided_sdk(mock_tools, env_var, tmp_path, capsys):
 
     # 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 = existing_android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(existing_android_sdk_root_path)()
@@ -165,14 +159,9 @@ def test_consistent_user_provided_sdk(mock_tools, tmp_path, capsys):
 
     # 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 = existing_android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(existing_android_sdk_root_path)()
@@ -208,14 +197,9 @@ def test_inconsistent_user_provided_sdk(mock_tools, tmp_path, capsys):
 
     # 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 = existing_android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(existing_android_sdk_root_path)()
@@ -245,18 +229,12 @@ def test_inconsistent_user_provided_sdk(mock_tools, tmp_path, capsys):
 @pytest.mark.parametrize("env_var", ["ANDROID_HOME", "ANDROID_SDK_ROOT"])
 def test_invalid_user_provided_sdk(mock_tools, env_var, tmp_path, capsys):
     """If the user's environment specifies an invalid Android SDK, it is ignored."""
-
     # Create `sdkmanager` and the license file
     # for the *briefcase* managed version of the SDK.
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
@@ -279,6 +257,115 @@ def test_invalid_user_provided_sdk(mock_tools, env_var, tmp_path, capsys):
     assert f"{env_var} does not point to an Android SDK" in capsys.readouterr().out
 
 
+@pytest.mark.parametrize("env_var", ["ANDROID_HOME", "ANDROID_SDK_ROOT"])
+def test_user_provided_sdk_wrong_cmdline_tools_ver(
+    mock_tools,
+    env_var,
+    tmp_path,
+    capsys,
+):
+    """If the user's environment specifies an Android SDK without the expected cmdline
+    tools and the cmdline-tools install fails, the Briefcase SDK is used."""
+    # Create `sdkmanager` and the license file for the *user's* version of the SDK
+    user_sdk_path = tmp_path / "other_sdk"
+    user_tools_bin = user_sdk_path / "cmdline-tools" / "6.0" / "bin"
+    user_tools_bin.mkdir(parents=True, mode=0o755)
+    (user_tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
+
+    # Create `sdkmanager` and the license file
+    # for the *briefcase* managed version of the SDK.
+    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "bin"
+    tools_bin.mkdir(parents=True, mode=0o755)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
+
+    # Pre-accept the license
+    accept_license(android_sdk_root_path)()
+
+    # Set the environment to specify an ANDROID_SDK_ROOT that doesn't exist
+    mock_tools.os.environ = {env_var: os.fsdecode(user_sdk_path)}
+
+    # Mock `cmdline-tools/latest` directory not existing
+    mock_tools.subprocess.run.side_effect = OSError
+
+    # Expect verify() to succeed
+    sdk = AndroidSDK.verify(mock_tools)
+
+    # No calls to download and nothing unpacked
+    mock_tools.download.file.assert_not_called()
+    mock_tools.shutil.unpack_archive.assert_not_called()
+
+    # Required Command-line Tools installed
+    mock_tools.subprocess.run.assert_called_once_with(
+        [
+            tmp_path
+            / "other_sdk"
+            / "cmdline-tools"
+            / "latest"
+            / "bin"
+            / SDKMANAGER_FILENAME,
+            f"cmdline-tools;{SDK_MGR_VER}",
+        ],
+        check=True,
+        stream_output=False,
+    )
+
+    # The returned SDK has the expected root path.
+    assert sdk.root_path == android_sdk_root_path
+
+    # User is informed about failed install and invalid SDK
+    output = capsys.readouterr().out
+    assert f"Failed to install cmdline-tools;{SDK_MGR_VER}" in output
+    assert "Incompatible Command-Line Tools Version" in output
+
+
+@pytest.mark.parametrize("env_var", ["ANDROID_HOME", "ANDROID_SDK_ROOT"])
+def test_user_provided_sdk_with_latest_cmdline_tools(
+    mock_tools,
+    env_var,
+    tmp_path,
+    capsys,
+):
+    """If the user's environment specifies an Android SDK without the expected cmdline
+    tools, the required cmdline-tools is installed in to it."""
+    # Create `sdkmanager` and the license file for the *user's* version of the SDK
+    user_sdk_path = tmp_path / "other_sdk"
+    user_tools_bin = user_sdk_path / "cmdline-tools" / "latest" / "bin"
+    user_tools_bin.mkdir(parents=True, mode=0o755)
+    (user_tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
+
+    # Pre-accept the license
+    accept_license(user_sdk_path)()
+
+    # Set the environment to specify an ANDROID_SDK_ROOT that doesn't exist
+    mock_tools.os.environ = {env_var: os.fsdecode(user_sdk_path)}
+
+    # Expect verify() to succeed
+    sdk = AndroidSDK.verify(mock_tools)
+
+    # No calls to download and nothing unpacked
+    mock_tools.download.file.assert_not_called()
+    mock_tools.shutil.unpack_archive.assert_not_called()
+
+    # Required Command-line Tools installed
+    mock_tools.subprocess.run.assert_called_once_with(
+        [
+            tmp_path
+            / "other_sdk"
+            / "cmdline-tools"
+            / "latest"
+            / "bin"
+            / SDKMANAGER_FILENAME,
+            f"cmdline-tools;{SDK_MGR_VER}",
+        ],
+        check=True,
+        stream_output=False,
+    )
+
+    # The returned SDK has the expected root path.
+    assert sdk.root_path == user_sdk_path
+
+
 def test_consistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys):
     """If the user's environment specifies an invalid Android SDK in both ANDROID_HOME
     and ANDROID_SDK_ROOT, they are ignored."""
@@ -286,14 +373,9 @@ def test_consistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys):
     # Create `sdkmanager` and the license file
     # for the *briefcase* managed version of the SDK.
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
@@ -326,14 +408,9 @@ def test_inconsistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys):
     # Create `sdkmanager` and the license file
     # for the *briefcase* managed version of the SDK.
     android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
+    tools_bin = android_sdk_root_path / "cmdline-tools" / SDK_MGR_VER / "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)
+    (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
     # Pre-accept the license
     accept_license(android_sdk_root_path)()
@@ -361,7 +438,7 @@ def test_inconsistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys):
     assert "ANDROID_HOME does not point to an Android SDK" in output
 
 
-def test_download_sdk(mock_tools, tmp_path):
+def test_download_sdk(mock_tools, tmp_path, capsys):
     """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"
@@ -382,7 +459,7 @@ def test_download_sdk(mock_tools, tmp_path):
     # Validate that the SDK was downloaded and unpacked
     url = (
         "https://dl.google.com/android/repository/"
-        f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
     )
     mock_tools.download.file.assert_called_once_with(
         url=url,
@@ -397,18 +474,73 @@ def test_download_sdk(mock_tools, tmp_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 commandline tools path exists
+    assert sdk.cmdline_tools_path.is_dir()
+
+    if platform.system() != "Windows":
+        # On non-Windows, ensure the unpacked binary was made executable
+        assert os.access(
+            cmdline_tools_base_path / SDK_MGR_VER / "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
+
+    assert "The Android SDK was not found" in capsys.readouterr().out
+
+
+def test_upgrade_existing_sdk(mock_tools, tmp_path, capsys):
+    """An existing SDK is upgraded if the required version of cmdline-tools isn't
+    installed."""
+    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
+    cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools"
+
+    # Mock an existing cmdline-tools install
+    (cmdline_tools_base_path / "latest").mkdir(parents=True)
+    (cmdline_tools_base_path / "8092744").touch()
+
+    # The download will produce a cached file.
+    cache_file = MagicMock()
+    mock_tools.download.file.return_value = cache_file
+
+    # Calling unpack will create files
+    mock_tools.shutil.unpack_archive.side_effect = mock_unpack
 
-    # The versioned form is a marker file; the tools path is a live directory
+    # Set up a side effect for accepting the license
+    mock_tools.subprocess.run.side_effect = accept_license(android_sdk_root_path)
+
+    # Call `verify()`
+    sdk = AndroidSDK.verify(mock_tools)
+
+    # Validate that the SDK was downloaded and unpacked
+    url = (
+        "https://dl.google.com/android/repository/"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
+    )
+    mock_tools.download.file.assert_called_once_with(
+        url=url,
+        download_path=mock_tools.base_path,
+        role="Android SDK Command-Line Tools",
+    )
+
+    mock_tools.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
     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",
+            cmdline_tools_base_path / SDK_MGR_VER / "bin" / "sdkmanager",
             os.X_OK,
         )
 
@@ -418,6 +550,12 @@ def test_download_sdk(mock_tools, tmp_path):
     # The returned SDK has the expected root path.
     assert sdk.root_path == android_sdk_root_path
 
+    # Ensure old cmdline-tools installs are cleaned up
+    assert not (cmdline_tools_base_path / "latest").exists()
+    assert not (cmdline_tools_base_path / "8092744").exists()
+
+    assert "Upgrading Android SDK..." in capsys.readouterr().out
+
 
 def test_download_sdk_legacy_install(mock_tools, tmp_path):
     """If the legacy SDK tools are present, they will be deleted."""
@@ -452,7 +590,7 @@ def test_download_sdk_legacy_install(mock_tools, tmp_path):
     # Validate that the SDK was downloaded and unpacked
     url = (
         "https://dl.google.com/android/repository/"
-        f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
     )
     mock_tools.download.file.assert_called_once_with(
         url=url,
@@ -468,17 +606,12 @@ def test_download_sdk_legacy_install(mock_tools, tmp_path):
     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
+            cmdline_tools_base_path / SDK_MGR_VER / "bin" / "sdkmanager", os.X_OK
         )
 
     # The legacy SDK tools have been removed
@@ -509,9 +642,9 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path):
     cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools"
 
     # 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()
+    (cmdline_tools_base_path / SDK_MGR_VER / "bin").mkdir(parents=True)
+    (cmdline_tools_base_path / SDK_MGR_VER / "bin" / "sdkmanager").touch(mode=0o644)
+    (cmdline_tools_base_path / SDK_MGR_DL_VER).touch()
 
     # The download will produce a cached file
     cache_file = MagicMock()
@@ -529,7 +662,7 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path):
     # Validate that the SDK was downloaded and unpacked
     url = (
         "https://dl.google.com/android/repository/"
-        f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
     )
     mock_tools.download.file.assert_called_once_with(
         url=url,
@@ -562,7 +695,7 @@ def test_raises_networkfailure_on_connectionerror(mock_tools):
     # The download was attempted
     url = (
         "https://dl.google.com/android/repository/"
-        f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
     )
     mock_tools.download.file.assert_called_once_with(
         url=url,
@@ -589,7 +722,7 @@ def test_detects_bad_zipfile(mock_tools, tmp_path):
     # The download attempt was made.
     url = (
         "https://dl.google.com/android/repository/"
-        f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip"
+        f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip"
     )
     mock_tools.download.file.assert_called_once_with(
         url=url,
diff --git a/tests/integrations/android_sdk/conftest.py b/tests/integrations/android_sdk/conftest.py
index c56b4859..d63ea0e0 100644
--- a/tests/integrations/android_sdk/conftest.py
+++ b/tests/integrations/android_sdk/conftest.py
@@ -9,6 +9,10 @@ from briefcase.integrations.download import Download
 from briefcase.integrations.java import JDK
 from briefcase.integrations.subprocess import Subprocess
 
+# current versions of Android SDK Manager
+SDK_MGR_VER = "9.0"
+SDK_MGR_DL_VER = "9477386"
+
 
 @pytest.fixture
 def mock_tools(mock_tools, tmp_path) -> ToolCache:
diff --git a/tests/platforms/android/gradle/test_open.py b/tests/platforms/android/gradle/test_open.py
index a36bdd14..cc36287f 100644
--- a/tests/platforms/android/gradle/test_open.py
+++ b/tests/platforms/android/gradle/test_open.py
@@ -7,6 +7,7 @@ import pytest
 
 from briefcase.console import Console, Log
 from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.android_sdk import AndroidSDK
 from briefcase.integrations.download import Download
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.android.gradle import GradleOpenCommand
@@ -21,7 +22,7 @@ def create_sdk_manager(tmp_path, extension=""):
         / "tools"
         / "android_sdk"
         / "cmdline-tools"
-        / "latest"
+        / AndroidSDK.SDK_MANAGER_VER
         / "bin"
         / f"sdkmanager{extension}",
         "Android SDK manager",
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index bc83f5df..3549bbcf 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -542,7 +542,7 @@ def test_log_file_extra(run_command, monkeypatch):
     run_command.tools.logger.save_log = True
     run_command.tools.logger.save_log_to_file(run_command)
 
-    sdk_manager = "/path/to/android_sdk/cmdline-tools/latest/bin/sdkmanager"
+    sdk_manager = f"/path/to/android_sdk/cmdline-tools/{AndroidSDK.SDK_MANAGER_VER}/bin/sdkmanager"
     if platform.system() == "Windows":
         sdk_manager += ".bat"
     run_command.tools.subprocess.check_output.assert_called_once_with(