Repository: beeware/briefcase

Issue:
Cannot support non-standard location for `.android` directory
### Describe the bug

The default location for the Android SDK's `.android` directory is `$HOME/.android` but it can be an arbitrary directory via `ANDROID_USER_HOME`. When Briefcase creates an AVD, it tries to modify its configuration afterwards; this fails because it assumes the AVD is located within `$HOME/.android`. More generally, enumerating the list of AVDs assumes this location as well.

### Steps to reproduce

Run `ANDROID_USER_HOME=/tmp/android briefcase run android` and create a new emulator.

```
❯ ANDROID_USER_HOME=/tmp/android briefcase run android -vv
Using JDK at /home/russell/.cache/briefcase/tools/java17
Using Android SDK at /home/russell/.cache/briefcase/tools/android_sdk
Android emulator is already installed.

>>> Running Command:
>>>     /home/russell/.cache/briefcase/tools/android_sdk/platform-tools/adb devices -l
>>> Working Directory:
>>>     /home/russell/tmp/beeware/helloworld
>>> Command Output:
>>>     List of devices attached
>>>     
>>> Return code: 0

>>> Running Command:
>>>     /home/russell/.cache/briefcase/tools/android_sdk/emulator/emulator -list-avds
>>> Working Directory:
>>>     /home/russell/tmp/beeware/helloworld
>>> Command Output:
>>>     beePhone
>>> Return code: 0

Select device:

  1) @beePhone (emulator)
  2) Create a new Android emulator

> 2

>>> Running Command:
>>>     /home/russell/.cache/briefcase/tools/android_sdk/emulator/emulator -list-avds
>>> Working Directory:
>>>     /home/russell/tmp/beeware/helloworld
>>> Command Output:
>>>     beePhone
>>> Return code: 0

You need to select a name for your new emulator. This is an identifier that
can be used to start the emulator in future. It should follow the same naming
conventions as a Python package (i.e., it may only contain letters, numbers,
hyphens and underscores). If you don't provide a name, Briefcase will use the
a default name 'beePhone2'.

Emulator name [beePhone2]: 
Device skin 'pixel_7_pro' already exists.

>>> Running Command:
>>>     /home/russell/.cache/briefcase/tools/android_sdk/cmdline-tools/9.0/bin/avdmanager --verbose create avd --name beePhone2 --abi x86_64 --package 'system-images;android-31;default;x86_64' --device pixel
>>> Working Directory:
>>>     /home/russell/tmp/beeware/helloworld
>>> Environment Overrides:
>>>     ANDROID_HOME=/home/russell/.cache/briefcase/tools/android_sdk
>>>     ANDROID_SDK_ROOT=/home/russell/.cache/briefcase/tools/android_sdk
>>>     JAVA_HOME=/home/russell/.cache/briefcase/tools/java17
>>> Command Output:
>>>     Loading local repository...                                                     
>>>     [=========                              ] 25% Loading local repository...       
>>>     [=========                              ] 25% Fetch remote repository...        
>>>     [=======================================] 100% Fetch remote repository...       
>>> Return code: 0
Creating Android emulator beePhone2... done
Adding extra device configuration... errored

Unable to read configuration of AVD @beePhone2

```

### Expected behavior

Briefcase respects `ANDROID_USER_HOME` or similar.

### Screenshots

_No response_

### Environment

- Operating System: pop os 22.04
- Python version: 3.10
- Software versions:
  - Briefcase: `0.3.18.dev72+ge141754e`

### Logs

[briefcase.2024_02_24-16_41_28.run.log](https://github.com/beeware/briefcase/files/14394489/briefcase.2024_02_24-16_41_28.run.log)


### Additional context

_No response_

Discussion:
rmartin16: Unfortunately, the emulator makes a bit of a mess of all this.

## tl;dr
- If the environment variables don't point to already valid directories, the emulator just ignores them and falls back to the default directories.
  - This is especially problematic if `~/.android` already contains AVDs because `emulator -list-avds` will return AVDs from `~/.android` but there isn't any context to know the `ANDROID_USER/AVD_HOME` env vars are being ignored
  - So, when these env vars are set and Briefcase tries to start an emulator from `~/.android`, things aren't going to go well

## Environment
### Assume this env:
```console
❯ env | grep ANDROID
ANDROID_HOME=/opt/sdks/Android/sdk/
```

### Assume these directory layouts:
<details>
<summary>Normal <code>~/.android</code> for an AVD called <code>android-home-dir</code></summary>

```console
❯ tree -d ~/.android
/home/russell/.android
├── avd
│   └── android-home-dir.avd
│       ├── modem_simulator
│       │   └── etc
│       │       └── modem_simulator
│       │           └── files
│       ├── snapshots
│       │   └── default_boot
│       └── tmpAdbCmds
└── cache
```
</details>

<details>
<summary><code>/tmp/android-user-home/</code> is a copy of <code>~/.android</code> for an AVD called <code>android-user-home</code></summary>

```console
❯ tree -d /tmp/android-user-home/
/tmp/android-user-home/
├── avd
│   └── android-user-home.avd
│       ├── modem_simulator
│       │   └── etc
│       │       └── modem_simulator
│       │           └── files
│       ├── snapshots
│       │   └── default_boot
│       └── tmpAdbCmds
└── cache
```
</details>

<details>
<summary><code>/tmp/android-avd-home/</code> which is just the <code>avd</code> directory of <code>~/.android</code> for an AVD called <code>avd-home</code></summary>

```console
❯ tree -d /tmp/android-avd-home/
/tmp/android-avd-home/
└── avd-home.avd
    ├── modem_simulator
    │   └── etc
    │       └── modem_simulator
    │           └── files
    ├── snapshots
    │   └── default_boot
    └── tmpAdbCmds
```
</details>

## Consider `emulator`
Using the most recently released version of the emulator
```console
❯ /opt/sdks/Android/sdk/emulator/emulator -version
INFO    | Android emulator version 33.1.24.0 (build_id 11237101) (CL:N/A)
```
Correctly finds the AVD in `~/.android/`
```console
❯ /opt/sdks/Android/sdk/emulator/emulator -list-avds
android-home-dir
```
Respects `ANDROID_AVD_HOME` and finds the AVD there
```console
❯ ANDROID_AVD_HOME=/tmp/android-avd-home/ /opt/sdks/Android/sdk/emulator/emulator -list-avds
avd-home
```
Ignores `ANDROID_USER_HOME` and finds the AVD in `~/.android/` (!!!!)
```console
❯ ANDROID_USER_HOME=/tmp/android-user-home/ /opt/sdks/Android/sdk/emulator/emulator -list-avds
android-home-dir
```
For shits and giggles.....I duplicated `/tmp/android-user-home` to `/tmp/.android` and apparently `emulator` does respect `ANDROID_SDK_HOME`....so there's that...
```console
❯ ANDROID_SDK_HOME=/tmp/ /opt/sdks/Android/sdk/emulator/emulator -list-avds
android-user-home
```
but `ANDROID_HOME` is not respected in the same way
```console
❯ ANDROID_HOME=/tmp/.android/ /opt/sdks/Android/sdk/emulator/emulator -list-avds
android-home-dir
```
Even more fascinatingly, `emulator` apparently doesn't use `ANDROID_EMULATOR_HOME` to find AVDs...it just uses it for whatever other data its putting in there...
```console
❯ ANDROID_EMULATOR_HOME=/tmp/android-user-home/ /opt/sdks/Android/sdk/emulator/emulator -list-avds
android-home-dir
```

## Consider `avdmanager`
Without env vars, creates the AVD in `~/.android`
```console
❯ /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager --verbose create avd --name android-no-env-vars --abi x86_64 --package 'system-images;android-31;default;x86_64' --device pixel
[=======================================] 100% Fetch remote repository...       

❯ tree -d ~/.android/
/home/russell/.android/
├── avd
│   ├── android-home-dir.avd
│   │   ├── modem_simulator
│   │   │   └── etc
│   │   │       └── modem_simulator
│   │   │           └── files
│   │   ├── snapshots
│   │   │   └── default_boot
│   │   └── tmpAdbCmds
│   └── android-no-env-vars.avd
└── cache
```
Respects `ANDROID_USER_HOME` and creates AVD in `/tmp/android-user-home`
```console
❯ ANDROID_USER_HOME=/tmp/android-user-home/ /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager --verbose create avd --name android-user-home-env-var --abi x86_64 --package 'system-images;android-31;default;x86_64' --device pixel
[=======================================] 100% Fetch remote repository...

❯ tree -d /tmp/android-user-home/
/tmp/android-user-home/
├── avd
│   ├── android-user-home.avd
│   │   ├── modem_simulator
│   │   │   └── etc
│   │   │       └── modem_simulator
│   │   │           └── files
│   │   ├── snapshots
│   │   │   └── default_boot
│   │   └── tmpAdbCmds
│   └── android-user-home-env-var.avd
└── cache
```
Respects `ANDROID_AVD_HOME` and creates AVD in `/tmp/android-avd-home`
```console
❯ ANDROID_AVD_HOME=/tmp/android-avd-home/ /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager --verbose create avd --name android-avd-home-env-var --abi x86_64 --package 'system-images;android-31;default;x86_64' --device pixel
[=======================================] 100% Fetch remote repository...       

❯ tree -d /tmp/android-avd-home/
/tmp/android-avd-home/
├── android-avd-home-env-var.avd
└── avd-home.avd
    ├── modem_simulator
    │   └── etc
    │       └── modem_simulator
    │           └── files
    ├── snapshots
    │   └── default_boot
    └── tmpAdbCmds
```
Allows `ANDROID_AVD_HOME` to override `ANDROID_USER_HOME`
```console
❯ ANDROID_USER_HOME=/tmp/android-user-home/ ANDROID_AVD_HOME=/tmp/android-avd-home/ /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager --verbose create avd --name android-avd-both-env-var --abi x86_64 --package 'system-images;android-31;default;x86_64' --device pixel
[=======================================] 100% Fetch remote repository...       

❯ tree -d /tmp/android-user-home/
/tmp/android-user-home/
├── avd
│   ├── android-user-home.avd
│   │   ├── modem_simulator
│   │   │   └── etc
│   │   │       └── modem_simulator
│   │   │           └── files
│   │   ├── snapshots
│   │   │   └── default_boot
│   │   └── tmpAdbCmds
│   └── android-user-home-env-var.avd
└── cache

11 directories

❯ tree -d /tmp/android-avd-home/
/tmp/android-avd-home/
├── android-avd-both-env-var.avd
├── android-avd-home-env-var.avd
└── avd-home.avd
    ├── modem_simulator
    │   └── etc
    │       └── modem_simulator
    │           └── files
    ├── snapshots
    │   └── default_boot
    └── tmpAdbCmds
```
rmartin16: Briefcase is already [assuming](https://github.com/beeware/briefcase/blob/35a2e37ec4363f74ad4ee1b477bad0c1bae295ea/src/briefcase/integrations/android_sdk.py#L131) some of the internal structure of the `avd` directory; so, if we extend that pattern here, we could inspect the filesystem while emulators are being enumerated to disqualify ones that don't exist where we're expecting.

Specifically, while iterating the output from `emulator -list-avds`, use the emulator name to check if `self.avd_path / f"{avd}.avd` is, in fact, a directory.

This would allow Briefcase to tolerate the emulator's fallback behavior; although, all this is likely to be confusing to users who have `ANDROID_AVD_HOME` set currently but have been using Briefcase with the `~/.android/avd` directory. After the upgrade, their AVDs won't be listed anymore...
rmartin16: Actually....maybe the answer is using `avdmanager` instead of `emulator` to find existing AVDs:
```console
❯ /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
android-home-dir
android-no-env-vars

❯ ANDROID_USER_HOME=/tmp/android-user-home /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
android-user-home
android-user-home-env-var

❯ ANDROID_USER_HOME=/tmp/android-user-home-none /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact

❯ ANDROID_AVD_HOME=/tmp/android-avd-home /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
android-avd-both-env-var
android-avd-home-env-var

❯ ANDROID_AVD_HOME=/tmp/android-avd-home-none /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
android-home-dir
android-no-env-vars

❯ ANDROID_USER_HOME=/tmp/android-user-home-none ANDROID_AVD_HOME=/tmp/android-avd-home-none /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact

❯ ANDROID_USER_HOME=/tmp/android-user-home-none ANDROID_AVD_HOME=/tmp/android-avd-home /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
android-avd-both-env-var
android-avd-home-env-var

❯ ANDROID_USER_HOME=/tmp/android-user-home ANDROID_AVD_HOME=/tmp/android-avd-home-none /opt/sdks/Android/sdk/cmdline-tools/9.0/bin/avdmanager list avd --compact
```

Notably:
- `avdmanager` will use `ANDROID_AVD_HOME` if the directory exists...otherwise, it falls back to the defaults
- `avdmanager` will just create `ANDROID_USER_HOME` if the directory doesn't exist...and not fall back to defaults
- `ANDROID_AVD_HOME` appears to take precedence over `ANDROID_USER_HOME`

This seems like much more sane behavior....although, we'll want to check if `ANDROID_USER_HOME` doesn't exist yet to avoid `avdmanager` eagerly creating it.

Furthermore, I'll need to explore the behavior around an already running emulator that doesn't exist inside either of the directories from these env vars....ideally, such a running emulator would still be usable by Briefcase.
freakboy3742: > Actually....maybe the answer is using `avdmanager` instead of `emulator` to find existing AVDs:
...
> Notably:
> 
> * `avdmanager` will use `ANDROID_AVD_HOME` if the directory exists...otherwise, it falls back to the defaults
> * `avdmanager` will just create `ANDROID_USER_HOME` if the directory doesn't exist...and not fall back to defaults
> * `ANDROID_AVD_HOME` appears to take precedence over `ANDROID_USER_HOME`
> 
> This seems like much more sane behavior....although, we'll want to check if `ANDROID_USER_HOME` doesn't exist yet to avoid `avdmanager` eagerly creating it.

Agreed - the only point of concern I have is the "fallback to defaults". It's easy enough to make a typo in a directory name, think you've set an environment variable and then discover your writing to a default location. On the one hand, this *is* the behavior of Android itself, so maybe we shouldn't fight it too hard; but on the other hand, we're in a position to at least *validate* if the value is going to cause a fallback to defaults and warn the user.

rmartin16: > Agreed - the only point of concern I have is the "fallback to defaults". It's easy enough to make a typo in a directory name, think you've set an environment variable and then discover your writing to a default location. On the one hand, this _is_ the behavior of Android itself, so maybe we shouldn't fight it too hard; but on the other hand, we're in a position to at least _validate_ if the value is going to cause a fallback to defaults and warn the user.

So, first, it is clear that no one is putting together QA plans to ensure these tools don't have nasty corner cases where behavior is completely surprising 😤

Second....I think we can avoid the use of default directories for `avdmanager` despite this.

If a user sets `ANDROID_HOME_USER`, then this all works as expected because `avdmanager` always respects it.

If a user sets only `ANDROID_AVD_HOME`, then `avdmanager` will respect it if we just ensure the directory exists first.

In either case, we should also avoid listing AVDs with `avdmanager` if the directory doesn't exist...because then `avdmanager` will create it.

I think this is the best we can do; then, if there's a typo but AVDs exist in the "real" directory, we won't create the typo directory and the user won't see any of their AVDs listed....prompting investigation. Alternatively, if the directory has a typo and the user attempts to create an AVD with Briefcase, well, the typo directory comes to life.

Patch:
diff --git a/changes/1665.feature.rst b/changes/1665.feature.rst
new file mode 100644
index 00000000..1cf0153c
--- /dev/null
+++ b/changes/1665.feature.rst
@@ -0,0 +1,1 @@
+The environment variables ``ANDROID_USER_HOME`` and ``ANDROID_AVD_HOME`` are now respected and passed through to Android SDK commands. This allows for storing AVDs for the Android emulator outside of the home directory.
diff --git a/changes/1671.misc.rst b/changes/1671.misc.rst
deleted file mode 100644
index 518d3740..00000000
--- a/changes/1671.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated coverage[toml] from 7.4.1 to 7.4.3.
diff --git a/changes/1672.misc.rst b/changes/1672.misc.rst
deleted file mode 100644
index 9a00163d..00000000
--- a/changes/1672.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated pytest from 8.0.1 to 8.0.2.
diff --git a/docs/reference/platforms/android/gradle.rst b/docs/reference/platforms/android/gradle.rst
index b969e31c..d36c8988 100644
--- a/docs/reference/platforms/android/gradle.rst
+++ b/docs/reference/platforms/android/gradle.rst
@@ -15,7 +15,24 @@ Gradle project
 
 When generating an Android project, Briefcase produces a Gradle project.
 
-Gradle requires an install of the Android SDK and a Java 17 JDK.
+Environment
+===========
+
+Gradle requires an install of a Java 17 JDK and the Android SDK.
+
+If the methods below fail to find an Android SDK or Java JDK, Briefcase will
+download and install an isolated copy in its data directory.
+
+Java JDK
+--------
+
+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
+not set, Briefcase will use the ``/usr/libexec/java_home`` tool to find an
+existing JDK install.
+
+Android SDK
+-----------
 
 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
@@ -24,13 +41,16 @@ present in the environment, Briefcase will honor the deprecated
 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
-not set, Briefcase will use the ``/usr/libexec/java_home`` tool to find an
-existing JDK install.
+Android Emulator
+----------------
 
-If the above methods fail to find an Android SDK or Java JDK, Briefcase will
-download and install an isolated copy in its data directory.
+By default, the Android emulator creates Android Virtual Devices (AVD) in the
+``.android`` directory in your user's home directory. As the AVDs can consume
+large amounts of disk space, they can be stored in an arbitrary directory via
+the ``ANDROID_USER_HOME`` and ``ANDROID_AVD_HOME`` environment variables.
+
+Packaging format
+================
 
 Briefcase supports three packaging formats for an Android app:
 
@@ -115,6 +135,61 @@ Android allows for some customization of the colors used by your app:
 * ``splash_background_color`` is the color of the splash background that
   displays while an app is loading.
 
+Additional options
+==================
+
+The following options can be provided at the command line when producing
+Android projects:
+
+run
+---
+
+``-d <device>`` / ``--device <device>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The device or emulator to target. Can be specified as:
+
+* ``@`` followed by an AVD name (e.g., ``@beePhone``); or
+* a device ID (a hexadecimal identifier associated with a specific hardware device);
+  or
+* a JSON dictionary specifying the properties of a device that will be created.
+  This dictionary must have, at a minimum, an AVD name:
+
+.. code-block:: console
+
+     $ briefcase run -d '{"avd":"new-device"}'
+
+  You may also specify:
+
+  - ``device_type`` (e.g., ``pixel``) - the type of device to emulate
+  - ``skin`` (e.g., ``pixel_3a``) - the skin to apply to the emulator
+  - ``system_image`` (e.g., ``system-images;android-31;default;arm64-v8a``) - the Android
+    system image to use in the emulator.
+
+  If any of these attributes are *not* specified, they will fall back
+  to reasonable defaults.
+
+``--Xemulator=<value>``
+~~~~~~~~~~~~~~~~~~~~~~~
+
+A configuration argument to be passed to the emulator on startup. For example,
+to start the emulator in "headless" mode (i.e., without a display window),
+specify ``--Xemulator=-no-window``. See `the Android documentation
+<https://developer.android.com/studio/run/emulator-commandline>`__ for details
+on the full list of options that can be provided.
+
+You may specify multiple ``--Xemulator`` arguments; each one specifies a
+single argument to pass to the emulator, in the order they are specified.
+
+``--shutdown-on-exit``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Instruct Briefcase to shut down the emulator when the run finishes. This is
+especially useful if you are running in headless mode, as the emulator will
+continue to run in the background, but there will be no visual manifestation
+that it is running. It may also be useful as a cleanup mechanism when running
+in a CI configuration.
+
 Application configuration
 =========================
 
@@ -235,61 +310,6 @@ If you want to manually specify a version code by defining ``version_code`` in
 your application configuration. If provided, this value will override any
 auto-generated value.
 
-Additional options
-==================
-
-The following options can be provided at the command line when producing
-Android projects:
-
-run
----
-
-``-d <device>`` / ``--device <device>``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-The device or emulator to target. Can be specified as:
-
-* ``@`` followed by an AVD name (e.g., ``@beePhone``); or
-* a device ID (a hexadecimal identifier associated with a specific hardware device);
-  or
-* a JSON dictionary specifying the properties of a device that will be created.
-  This dictionary must have, at a minimum, an AVD name:
-
-.. code-block:: console
-
-     $ briefcase run -d '{"avd":"new-device"}'
-
-  You may also specify:
-
-  - ``device_type`` (e.g., ``pixel``) - the type of device to emulate
-  - ``skin`` (e.g., ``pixel_3a``) - the skin to apply to the emulator
-  - ``system_image`` (e.g., ``system-images;android-31;default;arm64-v8a``) - the Android
-    system image to use in the emulator.
-
-  If any of these attributes are *not* specified, they will fall back
-  to reasonable defaults.
-
-``--Xemulator=<value>``
-~~~~~~~~~~~~~~~~~~~~~~~
-
-A configuration argument to be passed to the emulator on startup. For example,
-to start the emulator in "headless" mode (i.e., without a display window),
-specify ``--Xemulator=-no-window``. See `the Android documentation
-<https://developer.android.com/studio/run/emulator-commandline>`__ for details
-on the full list of options that can be provided.
-
-You may specify multiple ``--Xemulator`` arguments; each one specifies a
-single argument to pass to the emulator, in the order they are specified.
-
-``--shutdown-on-exit``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Instruct Briefcase to shut down the emulator when the run finishes. This is
-especially useful if you are running in headless mode, as the emulator will
-continue to run in the background, but there will be no visual manifestation
-that it is running. It may also be useful as a cleanup mechanism when running
-in a CI configuration.
-
 .. _android-permissions:
 
 Permissions
diff --git a/docs/reference/platforms/web/static.rst b/docs/reference/platforms/web/static.rst
index bb455bbf..d7679a43 100644
--- a/docs/reference/platforms/web/static.rst
+++ b/docs/reference/platforms/web/static.rst
@@ -48,6 +48,32 @@ Splash Image format
 
 Web projects do not support splash screens or installer images.
 
+Additional options
+==================
+
+The following options can be provided at the command line when producing
+web projects:
+
+run
+---
+
+``--host <ip or hostname>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The hostname or IP address that the development web server should be bound to.
+Defaults to ``localhost``.
+
+``-p <port>`` / ``--port <port>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The port that the development web server should be bound to. Defaults to ``8080``.
+If port ``8080`` is already in use, an arbitrary available port will be used.
+
+``--no-browser``
+~~~~~~~~~~~~~~~~
+
+Don't open a web browser after starting the development web server.
+
 Application configuration
 =========================
 
@@ -73,28 +99,3 @@ define a custom runtime, and change the default PyScript app name, you could use
     [[runtimes]]
     src = "https://example.com/custom/pyodide.js"
     """
-
-Additional options
-==================
-
-The following options can be provided at the command line when producing
-web projects:
-
-run
----
-
-``--host <ip or hostname>``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-The hostname or IP address that the development web server should be bound to.
-Defaults to ``localhost``.
-
-``-p <port>`` / ``--port <port>``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-The port that the development web server should be bound to. Defaults to ``8080``.
-
-``--no-browser``
-~~~~~~~~~~~~~~~~
-
-Don't open a web browser after starting the development web server.
diff --git a/pyproject.toml b/pyproject.toml
index 25b65cb8..19155686 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -96,12 +96,12 @@ dependencies = [
 # Extras used by developers *of* briefcase are pinned to specific versions to
 # ensure environment consistency.
 dev = [
-    "coverage[toml] == 7.4.3",
+    "coverage[toml] == 7.4.1",
     "coverage-conditional-plugin == 0.9.0",
     # Pre-commit 3.6.0 deprecated support for Python 3.8
     "pre-commit == 3.5.0 ; python_version < '3.9'",
     "pre-commit == 3.6.2 ; python_version >= '3.9'",
-    "pytest == 8.0.2",
+    "pytest == 8.0.1",
     "pytest-xdist == 3.5.0",
     "setuptools_scm == 8.0.4",
     "tox == 4.13.0",
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index f74aeda0..19f23435 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -55,7 +55,6 @@ class AndroidSDK(ManagedTool):
 
     def __init__(self, tools: ToolCache, root_path: Path):
         super().__init__(tools=tools)
-        self.dot_android_path = self.tools.home_path / ".android"
         self.root_path = root_path
 
         # A wrapper for testing purposes
@@ -93,6 +92,13 @@ class AndroidSDK(ManagedTool):
             f"commandlinetools-{platform_name}-{self.SDK_MANAGER_DOWNLOAD_VER}_latest.zip"
         )
 
+    @property
+    def dot_android_path(self) -> Path:
+        if user_home := self.tools.os.environ.get("ANDROID_USER_HOME"):
+            return Path(user_home)
+        else:
+            return self.tools.home_path / ".android"
+
     @property
     def cmdline_tools_path(self) -> Path:
         """Version-specific Command-line tools install root directory."""
@@ -124,18 +130,26 @@ class AndroidSDK(ManagedTool):
         return self.root_path / "emulator" / emulator
 
     @property
-    def avd_path(self) -> Path:
-        return self.dot_android_path / "avd"
+    def avd_home_path(self) -> Path:
+        if avd_home := self.tools.os.environ.get("ANDROID_AVD_HOME"):
+            return Path(avd_home)
+        else:
+            return self.dot_android_path / "avd"
+
+    def avd_path(self, avd: str) -> Path:
+        return self.avd_home_path / f"{avd}.avd"
 
-    def avd_config_filename(self, avd: str) -> Path:
-        return self.avd_path / f"{avd}.avd/config.ini"
+    def avd_config_filepath(self, avd: str) -> Path:
+        return self.avd_path(avd) / "config.ini"
 
     @property
     def env(self) -> dict[str, str]:
         return {
             "ANDROID_HOME": os.fsdecode(self.root_path),
             "ANDROID_SDK_ROOT": os.fsdecode(self.root_path),
-            "JAVA_HOME": str(self.tools.java.java_home),
+            "ANDROID_USER_HOME": os.fsdecode(self.dot_android_path),
+            "ANDROID_AVD_HOME": os.fsdecode(self.avd_home_path),
+            "JAVA_HOME": os.fsdecode(self.tools.java.java_home),
         }
 
     @property
@@ -480,19 +494,14 @@ its output for errors.
             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,
+                    self.root_path
+                    / f"cmdline-tools/latest/bin/{self.sdkmanager_filename}",
                     f"cmdline-tools;{self.SDK_MANAGER_VER}",
                 ],
+                env=self.env,
                 check=True,
                 stream_output=False,
             )
@@ -532,7 +541,7 @@ its output for errors.
     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.
+    in {self.avd_home_path} matching the emulator name.
 
 *************************************************************************
 """
@@ -557,7 +566,7 @@ its output for errors.
         try:
             # check_output always writes its output to debug
             self.tools.subprocess.check_output(
-                [os.fsdecode(self.sdkmanager_path), "--list_installed"],
+                [self.sdkmanager_path, "--list_installed"],
                 env=self.env,
             )
         except subprocess.CalledProcessError as e:
@@ -593,7 +602,7 @@ before you may use those tools.
             # Using subprocess.run() with no I/O redirection so the user sees
             # the full output and can send input.
             self.tools.subprocess.run(
-                [os.fsdecode(self.sdkmanager_path), "--licenses"],
+                [self.sdkmanager_path, "--licenses"],
                 env=self.env,
                 check=True,
                 stream_output=False,
@@ -641,11 +650,7 @@ connection.
         self.tools.logger.info("Downloading the Android emulator...")
         try:
             self.tools.subprocess.run(
-                [
-                    os.fsdecode(self.sdkmanager_path),
-                    "platform-tools",
-                    "emulator",
-                ],
+                [self.sdkmanager_path, "platform-tools", "emulator"],
                 env=self.env,
                 check=True,
                 stream_output=False,
@@ -771,10 +776,7 @@ connection.
         )
         try:
             self.tools.subprocess.run(
-                [
-                    os.fsdecode(self.sdkmanager_path),
-                    system_image,
-                ],
+                [self.sdkmanager_path, system_image],
                 env=self.env,
                 check=True,
                 stream_output=False,
@@ -831,11 +833,17 @@ connection.
 
     def emulators(self) -> list[str]:
         """Find the list of emulators that are available."""
+        # Only check for existing AVDs if the AVD home path exists; otherwise
+        # the call to avdmanager to list them will create the directory.
+        if not self.avd_home_path.is_dir():
+            return []
+
         try:
             # Capture `stderr` so that if the process exits with failure, the
             # stderr data is in `e.output`.
             output = self.tools.subprocess.check_output(
-                [os.fsdecode(self.emulator_path), "-list-avds"]
+                [self.avdmanager_path, "list", "avd", "--compact"],
+                env=self.env,
             ).strip()
             # AVD names are returned one per line.
             if len(output) == 0:
@@ -848,7 +856,8 @@ connection.
         """Find the devices that are attached and available to ADB."""
         try:
             output = self.tools.subprocess.check_output(
-                [os.fsdecode(self.adb_path), "devices", "-l"]
+                [self.adb_path, "devices", "-l"],
+                env=self.env,
             ).strip()
             # Process the output of `adb devices -l`.
             # The first line is header information.
@@ -1187,17 +1196,20 @@ In future, you can specify this device by running:
         if system_image is None:
             system_image = self.DEFAULT_SYSTEM_IMAGE
 
-        # Ensure the required skin is available.
+        # Ensure the required skin is available
         self.verify_emulator_skin(skin)
 
-        # Ensure the required system image is available.
+        # Ensure the required system image is available
         self.verify_system_image(system_image)
 
+        # Ensure AVD home exists so avdmanager doesn't use default directory
+        self.avd_home_path.mkdir(parents=True, exist_ok=True)
+
         with self.tools.input.wait_bar(f"Creating Android emulator {avd}..."):
             try:
                 self.tools.subprocess.check_output(
                     [
-                        os.fsdecode(self.avdmanager_path),
+                        self.avdmanager_path,
                         "--verbose",
                         "create",
                         "avd",
@@ -1238,7 +1250,7 @@ In future, you can specify this device by running:
         # Parse the existing config into key-value pairs
         avd_config = {}
         try:
-            with self.avd_config_filename(avd).open("r", encoding="utf-8") as f:
+            with self.avd_config_filepath(avd).open("r", encoding="utf-8") as f:
                 for line in f:
                     try:
                         key, value = line.rstrip().split("=", 1)
@@ -1265,7 +1277,7 @@ In future, you can specify this device by running:
         avd_config.update(updates)
 
         # Write the update configuration.
-        with self.avd_config_filename(avd).open("w", encoding="utf-8") as f:
+        with self.avd_config_filepath(avd).open("w", encoding="utf-8") as f:
             for key, value in avd_config.items():
                 f.write(f"{key}={value}\n")
 
@@ -1289,13 +1301,7 @@ In future, you can specify this device by running:
             extra_args = []
 
         emulator_popen = self.tools.subprocess.Popen(
-            [
-                os.fsdecode(self.emulator_path),
-                f"@{avd}",
-                "-dns-server",
-                "8.8.8.8",
-            ]
-            + extra_args,
+            [self.emulator_path, f"@{avd}", "-dns-server", "8.8.8.8"] + extra_args,
             env=self.env,
             stdout=subprocess.PIPE,
             stderr=subprocess.STDOUT,
@@ -1480,7 +1486,7 @@ class ADB:
         try:
             output = self.tools.subprocess.check_output(
                 [
-                    os.fsdecode(self.tools.android_sdk.adb_path),
+                    self.tools.android_sdk.adb_path,
                     "-s",
                     self.device,
                 ]
@@ -1488,10 +1494,11 @@ class ADB:
                     (os.fsdecode(arg) if isinstance(arg, Path) else arg)
                     for arg in arguments
                 ],
+                env=self.tools.android_sdk.env,
                 quiet=quiet,
             )
             # add returns status code 0 in the case of failure. The only tangible evidence
-            # of failure is the message "Failure [INSTALL_FAILED_OLDER_SDK]" in the,
+            # of failure is the message "Failure [INSTALL_FAILED_OLDER_SDK]" in the
             # console output; so if that message exists in the output, raise an exception.
             if "Failure [INSTALL_FAILED_OLDER_SDK]" in output:
                 raise BriefcaseCommandError(
@@ -1594,7 +1601,7 @@ Activity class not found while starting app.
         # See #1425 for details.
         return self.tools.subprocess.Popen(
             [
-                os.fsdecode(self.tools.android_sdk.adb_path),
+                self.tools.android_sdk.adb_path,
                 "-s",
                 self.device,
                 "logcat",
@@ -1622,7 +1629,7 @@ Activity class not found while starting app.
             # See #1425 for details.
             self.tools.subprocess.run(
                 [
-                    os.fsdecode(self.tools.android_sdk.adb_path),
+                    self.tools.android_sdk.adb_path,
                     "-s",
                     self.device,
                     "logcat",


Test patch:
diff --git a/tests/integrations/android_sdk/ADB/test_kill.py b/tests/integrations/android_sdk/ADB/test_kill.py
index 81b15b83..05360aa3 100644
--- a/tests/integrations/android_sdk/ADB/test_kill.py
+++ b/tests/integrations/android_sdk/ADB/test_kill.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 from unittest.mock import MagicMock
 
@@ -15,12 +14,13 @@ def test_kill(mock_tools, adb):
     # Validate call parameters.
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(mock_tools.android_sdk.adb_path),
+            mock_tools.android_sdk.adb_path,
             "-s",
             "exampleDevice",
             "emu",
             "kill",
         ],
+        env=mock_tools.android_sdk.env,
         quiet=False,
     )
 
diff --git a/tests/integrations/android_sdk/ADB/test_logcat.py b/tests/integrations/android_sdk/ADB/test_logcat.py
index c9ee173e..3013122b 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 from unittest import mock
 
@@ -15,7 +14,7 @@ def test_logcat(mock_tools, adb):
     # Validate call parameters.
     mock_tools.subprocess.Popen.assert_called_once_with(
         [
-            os.fsdecode(mock_tools.android_sdk.adb_path),
+            mock_tools.android_sdk.adb_path,
             "-s",
             "exampleDevice",
             "logcat",
diff --git a/tests/integrations/android_sdk/ADB/test_logcat_tail.py b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
index f80c449b..c22cf3c7 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat_tail.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 from datetime import datetime
 from unittest.mock import MagicMock
@@ -16,7 +15,7 @@ def test_logcat_tail(mock_tools, adb):
     # Validate call parameters.
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(mock_tools.android_sdk.adb_path),
+            mock_tools.android_sdk.adb_path,
             "-s",
             "exampleDevice",
             "logcat",
diff --git a/tests/integrations/android_sdk/ADB/test_run.py b/tests/integrations/android_sdk/ADB/test_run.py
index c3cf7106..0d9e5ee8 100644
--- a/tests/integrations/android_sdk/ADB/test_run.py
+++ b/tests/integrations/android_sdk/ADB/test_run.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 import sys
 from pathlib import Path
@@ -15,17 +14,14 @@ def test_simple_command(mock_tools, adb, tmp_path):
     # Check that adb was invoked with the expected commands
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(
-                tmp_path
-                / "sdk"
-                / "platform-tools"
-                / f"adb{'.exe' if sys.platform == 'win32' else ''}"
-            ),
+            tmp_path
+            / f"sdk/platform-tools/adb{'.exe' if sys.platform == 'win32' else ''}",
             "-s",
             "exampleDevice",
             "example",
             "command",
         ],
+        env=mock_tools.android_sdk.env,
         quiet=False,
     )
 
@@ -37,17 +33,14 @@ def test_quiet_command(mock_tools, adb, tmp_path):
     # Check that adb was invoked with the expected commands
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(
-                tmp_path
-                / "sdk"
-                / "platform-tools"
-                / f"adb{'.exe' if sys.platform == 'win32' else ''}"
-            ),
+            tmp_path
+            / f"sdk/platform-tools/adb{'.exe' if sys.platform == 'win32' else ''}",
             "-s",
             "exampleDevice",
             "example",
             "command",
         ],
+        env=mock_tools.android_sdk.env,
         quiet=True,
     )
 
@@ -86,17 +79,14 @@ def test_error_handling(mock_tools, adb, name, exception, tmp_path):
     # Check that adb was invoked as expected
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(
-                tmp_path
-                / "sdk"
-                / "platform-tools"
-                / f"adb{'.exe' if sys.platform == 'win32' else ''}"
-            ),
+            tmp_path
+            / f"sdk/platform-tools/adb{'.exe' if sys.platform == 'win32' else ''}",
             "-s",
             "exampleDevice",
             "example",
             "command",
         ],
+        env=mock_tools.android_sdk.env,
         quiet=False,
     )
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
index 4c864be5..1270dbc8 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test__create_emulator.py
@@ -1,6 +1,5 @@
-import os
 import subprocess
-from unittest.mock import ANY, MagicMock
+from unittest.mock import ANY, MagicMock, PropertyMock
 
 import pytest
 
@@ -88,7 +87,7 @@ def test_create_emulator(
     # avdmanager was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.avdmanager_path),
+            android_sdk.avdmanager_path,
             "--verbose",
             "create",
             "avd",
@@ -161,7 +160,7 @@ def test_create_emulator_with_defaults(
     # avdmanager was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.avdmanager_path),
+            android_sdk.avdmanager_path,
             "--verbose",
             "create",
             "avd",
@@ -184,6 +183,69 @@ def test_create_emulator_with_defaults(
     assert "skin.name=pixel_7_pro" in config
 
 
+def test_create_emulator_with_nonexistent_avd_home(
+    mock_tools,
+    android_sdk,
+    tmp_path,
+    monkeypatch,
+):
+    """Ensure a AVD home directory is created for a new emulator."""
+    # Mock the hardware and operating system to specific values
+    mock_tools.host_os = "Darwin"
+    mock_tools.host_arch = "x86_64"
+
+    # Mock system image and skin verification
+    android_sdk.verify_system_image = MagicMock()
+    android_sdk.verify_emulator_skin = MagicMock()
+
+    # Mock a custom location for the AVDs
+    android_sdk.update_emulator_config = MagicMock()
+    avd_home_path = tmp_path / "my-avds"
+    monkeypatch.setattr(
+        type(android_sdk),
+        "avd_home_path",
+        PropertyMock(return_value=avd_home_path),
+    )
+    assert avd_home_path.is_dir() is False
+
+    # Create the emulator
+    android_sdk._create_emulator(
+        avd="new-emulator",
+        device_type="slab",
+        skin="slab_skin",
+        system_image="system-images;android-42;default;gothic",
+    )
+
+    # The system image was verified
+    android_sdk.verify_system_image.assert_called_once_with(
+        "system-images;android-42;default;gothic"
+    )
+
+    # The emulator skin was verified
+    android_sdk.verify_emulator_skin.assert_called_once_with("slab_skin")
+
+    # avdmanager was invoked
+    mock_tools.subprocess.check_output.assert_called_once_with(
+        [
+            android_sdk.avdmanager_path,
+            "--verbose",
+            "create",
+            "avd",
+            "--name",
+            "new-emulator",
+            "--abi",
+            "x86_64",
+            "--package",
+            "system-images;android-42;default;gothic",
+            "--device",
+            "slab",
+        ],
+        env=android_sdk.env,
+    )
+
+    assert avd_home_path.is_dir()
+
+
 def test_create_failure(mock_tools, android_sdk):
     """If avdmanager fails, an error is raised."""
     # Mock system image and skin verification
@@ -208,7 +270,7 @@ def test_create_failure(mock_tools, android_sdk):
     # avdmanager was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.avdmanager_path),
+            android_sdk.avdmanager_path,
             "--verbose",
             "create",
             "avd",
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_emulators.py b/tests/integrations/android_sdk/AndroidSDK/test_emulators.py
index 1619345c..e7ddb4f9 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_emulators.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_emulators.py
@@ -5,8 +5,15 @@ import pytest
 from briefcase.exceptions import BriefcaseCommandError
 
 
+def test_no_avd_home(mock_tools, android_sdk):
+    """If the AVD home directory doesn't exist, an empty list is returned."""
+    assert not android_sdk.avd_home_path.is_dir()
+    assert android_sdk.emulators() == []
+
+
 def test_no_emulators(mock_tools, android_sdk):
     """If there are no emulators, an empty list is returned."""
+    android_sdk.avd_home_path.mkdir(parents=True, exist_ok=True)
     mock_tools.subprocess.check_output.return_value = ""
 
     assert android_sdk.emulators() == []
@@ -14,6 +21,7 @@ def test_no_emulators(mock_tools, android_sdk):
 
 def test_one_emulator(mock_tools, android_sdk):
     """If there is a single emulator, it is returned."""
+    android_sdk.avd_home_path.mkdir(parents=True, exist_ok=True)
     mock_tools.subprocess.check_output.return_value = "first\n"
 
     assert android_sdk.emulators() == ["first"]
@@ -21,6 +29,7 @@ def test_one_emulator(mock_tools, android_sdk):
 
 def test_multiple_emulators(mock_tools, android_sdk):
     """If there are multiple emulators, they are all returned."""
+    android_sdk.avd_home_path.mkdir(parents=True, exist_ok=True)
     mock_tools.subprocess.check_output.return_value = "first\nsecond\nthird\n"
 
     assert android_sdk.emulators() == ["first", "second", "third"]
@@ -28,8 +37,9 @@ def test_multiple_emulators(mock_tools, android_sdk):
 
 def test_adb_error(mock_tools, android_sdk):
     """If there is a problem invoking adb, an error is returned."""
+    android_sdk.avd_home_path.mkdir(parents=True, exist_ok=True)
     mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        returncode=69, cmd="adb devices -l"
+        returncode=69, cmd="avdmanager list avd --compact"
     )
 
     with pytest.raises(BriefcaseCommandError):
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
index dbeb5734..731a756b 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 
 import pytest
@@ -11,7 +10,7 @@ def test_list_packages(mock_tools, android_sdk):
     android_sdk.list_packages()
 
     mock_tools.subprocess.check_output.assert_called_once_with(
-        [os.fsdecode(android_sdk.sdkmanager_path), "--list_installed"],
+        [android_sdk.sdkmanager_path, "--list_installed"],
         env=android_sdk.env,
     )
 
@@ -25,6 +24,6 @@ def test_list_packages_failure(mock_tools, android_sdk):
         android_sdk.list_packages()
 
     mock_tools.subprocess.check_output.assert_called_once_with(
-        [os.fsdecode(android_sdk.sdkmanager_path), "--list_installed"],
+        [android_sdk.sdkmanager_path, "--list_installed"],
         env=android_sdk.env,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_properties.py b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
index 7c4fdb56..8b38ab53 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_properties.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
@@ -86,8 +86,55 @@ def test_emulator_path(mock_tools, android_sdk, host_os, emulator_name):
     )
 
 
+def test_dot_android_path(mock_tools, android_sdk, tmp_path):
+    """Default user android path is $HOME/.android/avd."""
+    assert android_sdk.dot_android_path == tmp_path / "home/.android"
+
+
+def test_dot_android_path_env_var(mock_tools, android_sdk, monkeypatch):
+    """ANDROID_AVD_HOME is respected as AVD path."""
+    mock_tools.os.environ = {"ANDROID_USER_HOME": "/my/android"}
+    assert android_sdk.dot_android_path == Path("/my/android")
+
+
+def test_avd_home_path(mock_tools, android_sdk, tmp_path):
+    """Default AVD path is $HOME/.android/avd."""
+    assert android_sdk.avd_home_path == tmp_path / "home/.android/avd"
+
+
+def test_avd_home_path_env_var(mock_tools, android_sdk, monkeypatch):
+    """ANDROID_AVD_HOME is respected as AVD path."""
+    mock_tools.os.environ = {"ANDROID_AVD_HOME": "/my/avds"}
+    assert android_sdk.avd_home_path == Path("/my/avds")
+
+
 def test_avd_path(mock_tools, android_sdk, tmp_path):
-    assert android_sdk.avd_path == tmp_path / "home/.android/avd"
+    """Default path for AVD is correct."""
+    assert (
+        android_sdk.avd_path(avd="my-avd") == tmp_path / "home/.android/avd/my-avd.avd"
+    )
+
+
+def test_avd_path_env_var(mock_tools, android_sdk, tmp_path, monkeypatch):
+    """ANDROID_AVD_HOME is respected for AVD path."""
+    mock_tools.os.environ = {"ANDROID_AVD_HOME": "/my/avds"}
+    assert android_sdk.avd_path(avd="my-avd") == Path("/my/avds/my-avd.avd")
+
+
+def test_avd_config_filepath(mock_tools, android_sdk, tmp_path):
+    """Default AVD config file is correct."""
+    assert (
+        android_sdk.avd_config_filepath(avd="my-avd")
+        == tmp_path / "home/.android/avd/my-avd.avd/config.ini"
+    )
+
+
+def test_avd_config_filepath_env_var(mock_tools, android_sdk, tmp_path, monkeypatch):
+    """ANDROID_AVD_HOME is respected for AVD config filepath."""
+    mock_tools.os.environ = {"ANDROID_AVD_HOME": "/my/avds"}
+    assert android_sdk.avd_config_filepath(avd="my-avd") == Path(
+        "/my/avds/my-avd.avd/config.ini"
+    )
 
 
 def test_simple_env(mock_tools, android_sdk, tmp_path):
@@ -96,6 +143,8 @@ def test_simple_env(mock_tools, android_sdk, tmp_path):
         "JAVA_HOME": os.fsdecode(Path("/path/to/jdk")),
         "ANDROID_HOME": os.fsdecode(tmp_path / "sdk"),
         "ANDROID_SDK_ROOT": os.fsdecode(tmp_path / "sdk"),
+        "ANDROID_USER_HOME": os.fsdecode(tmp_path / "home/.android"),
+        "ANDROID_AVD_HOME": os.fsdecode(tmp_path / "home/.android/avd"),
     }
 
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
index a0339daf..3dacb259 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 from unittest.mock import MagicMock, call
 
@@ -109,7 +108,7 @@ def test_start_emulator(mock_tools, android_sdk):
     # The process was started.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
@@ -190,7 +189,7 @@ def test_start_emulator_fast_start(mock_tools, android_sdk):
     # The process was started.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
@@ -285,7 +284,7 @@ def test_emulator_fail_to_start(mock_tools, android_sdk):
     # The process was started.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
@@ -389,7 +388,7 @@ def test_emulator_fail_to_boot(mock_tools, android_sdk):
     # The process was started.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
@@ -458,7 +457,7 @@ def test_emulator_ctrl_c(mock_tools, android_sdk, emulator_comm, expected_log, c
     # The process was started.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
@@ -524,7 +523,7 @@ def test_start_emulator_extra_args(mock_tools, android_sdk):
     # The process was started with the headless test options.
     mock_tools.subprocess.Popen.assert_called_with(
         [
-            os.fsdecode(android_sdk.emulator_path),
+            android_sdk.emulator_path,
             "@idleEmulator",
             "-dns-server",
             "8.8.8.8",
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index 4e393a05..2619f85c 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -1,6 +1,7 @@
 import os
 import platform
 import shutil
+from pathlib import Path
 from unittest.mock import MagicMock
 
 import pytest
@@ -320,7 +321,7 @@ def test_user_provided_sdk_wrong_cmdline_tools_ver(
     # 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 = android_sdk_root_path / f"cmdline-tools/{SDK_MGR_VER}/bin"
     tools_bin.mkdir(parents=True, mode=0o755)
     (tools_bin / SDKMANAGER_FILENAME).touch(mode=0o755)
 
@@ -343,14 +344,16 @@ def test_user_provided_sdk_wrong_cmdline_tools_ver(
     # Required Command-line Tools installed
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            tmp_path
-            / "other_sdk"
-            / "cmdline-tools"
-            / "latest"
-            / "bin"
-            / SDKMANAGER_FILENAME,
+            tmp_path / f"other_sdk/cmdline-tools/latest/bin/{SDKMANAGER_FILENAME}",
             f"cmdline-tools;{SDK_MGR_VER}",
         ],
+        env={
+            "ANDROID_HOME": f"{tmp_path / 'other_sdk'}",
+            "ANDROID_SDK_ROOT": f"{tmp_path / 'other_sdk'}",
+            "ANDROID_USER_HOME": f"{tmp_path / 'home/.android'}",
+            "ANDROID_AVD_HOME": f"{tmp_path / 'home/.android/avd'}",
+            "JAVA_HOME": str(Path("/path/to/jdk")),
+        },
         check=True,
         stream_output=False,
     )
@@ -395,14 +398,10 @@ def test_user_provided_sdk_with_latest_cmdline_tools(
     # Required Command-line Tools installed
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            tmp_path
-            / "other_sdk"
-            / "cmdline-tools"
-            / "latest"
-            / "bin"
-            / SDKMANAGER_FILENAME,
+            tmp_path / f"other_sdk/cmdline-tools/latest/bin/{SDKMANAGER_FILENAME}",
             f"cmdline-tools;{SDK_MGR_VER}",
         ],
+        env=sdk.env,
         check=True,
         stream_output=False,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
index 27c6f9f4..c65cfa55 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 import sys
 
@@ -62,7 +61,7 @@ def test_installs_android_emulator(mock_tools, android_sdk):
 
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.sdkmanager_path),
+            android_sdk.sdkmanager_path,
             "platform-tools",
             "emulator",
         ],
@@ -84,7 +83,7 @@ def test_partial_android_emulator_install(mock_tools, android_sdk):
 
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.sdkmanager_path),
+            android_sdk.sdkmanager_path,
             "platform-tools",
             "emulator",
         ],
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
index 60e97f3a..0fd4eb73 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
@@ -1,4 +1,3 @@
-import os
 import subprocess
 
 import pytest
@@ -35,7 +34,7 @@ def test_verify_license_prompts_for_licenses_and_exits_if_you_agree(
     mock_tools.subprocess.run.side_effect = accept_license
     android_sdk.verify_license()
     mock_tools.subprocess.run.assert_called_once_with(
-        [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
+        [android_sdk.sdkmanager_path, "--licenses"],
         env=android_sdk.env,
         check=True,
         stream_output=False,
@@ -50,7 +49,7 @@ def test_verify_license_handles_sdkmanager_crash(mock_tools, android_sdk):
         android_sdk.verify_license()
 
     mock_tools.subprocess.run.assert_called_once_with(
-        [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
+        [android_sdk.sdkmanager_path, "--licenses"],
         env=android_sdk.env,
         check=True,
         stream_output=False,
@@ -66,7 +65,7 @@ def test_verify_license_insists_on_agreement(mock_tools, android_sdk):
         android_sdk.verify_license()
 
     mock_tools.subprocess.run.assert_called_once_with(
-        [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
+        [android_sdk.sdkmanager_path, "--licenses"],
         env=android_sdk.env,
         check=True,
         stream_output=False,
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
index b661e6e9..22b351d1 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
@@ -1,4 +1,3 @@
-import os
 import platform
 from subprocess import CalledProcessError
 
@@ -68,7 +67,7 @@ def test_incompatible_abi(mock_tools, android_sdk, capsys):
     # The system image was installed.
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.sdkmanager_path),
+            android_sdk.sdkmanager_path,
             "system-images;android-31;default;anything",
         ],
         env=android_sdk.env,
@@ -105,7 +104,7 @@ def test_new_system_image(mock_tools, android_sdk):
     # The system image was installed.
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.sdkmanager_path),
+            android_sdk.sdkmanager_path,
             "system-images;android-31;default;x86_64",
         ],
         env=android_sdk.env,
@@ -135,7 +134,7 @@ def test_problem_downloading_system_image(mock_tools, android_sdk):
     # An attempt to install the system image was made
     mock_tools.subprocess.run.assert_called_once_with(
         [
-            os.fsdecode(android_sdk.sdkmanager_path),
+            android_sdk.sdkmanager_path,
             "system-images;android-31;default;x86_64",
         ],
         env=android_sdk.env,
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index 3db1db2e..4f024696 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -3,7 +3,6 @@ import os
 import platform
 import sys
 import time
-from os.path import normpath
 from pathlib import Path
 from unittest import mock
 
@@ -53,6 +52,7 @@ def run_command(tmp_path, first_app_config, jdk):
     command.tools.requests = mock.MagicMock(spec_set=requests)
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     command.tools.sys = mock.MagicMock(spec_set=sys)
+    command.tools.home_path = tmp_path / "home"
 
     command._stream_app_logs = mock.MagicMock()
 
@@ -524,7 +524,7 @@ def test_run_idle_device(run_command, first_app_config):
     )
 
 
-def test_log_file_extra(run_command, monkeypatch):
+def test_log_file_extra(run_command, tmp_path, monkeypatch):
     """Android commands register a log file extra to list SDK packages."""
     mock_android_sdk_verify = mock.MagicMock(return_value=run_command.tools.android_sdk)
     monkeypatch.setattr(AndroidSDK, "verify", mock_android_sdk_verify)
@@ -545,14 +545,17 @@ 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 = f"/path/to/android_sdk/cmdline-tools/{AndroidSDK.SDK_MANAGER_VER}/bin/sdkmanager"
-    if platform.system() == "Windows":
-        sdk_manager += ".bat"
+    sdk_manager = Path(
+        f"/path/to/android_sdk/cmdline-tools/{AndroidSDK.SDK_MANAGER_VER}"
+        f"/bin/sdkmanager{'.bat' if platform.system() == 'Windows' else ''}"
+    )
     run_command.tools.subprocess.check_output.assert_called_once_with(
-        [normpath(sdk_manager), "--list_installed"],
+        [sdk_manager, "--list_installed"],
         env={
             "ANDROID_HOME": str(run_command.tools.android_sdk.root_path),
             "ANDROID_SDK_ROOT": str(run_command.tools.android_sdk.root_path),
+            "ANDROID_USER_HOME": f"{tmp_path / 'home/.android'}",
+            "ANDROID_AVD_HOME": f"{tmp_path / 'home/.android/avd'}",
             "JAVA_HOME": str(run_command.tools.java.java_home),
         },
     )