Repository: beeware/briefcase

Issue:
Add APK packaging output for Android Gradle backend
### What is the problem or limitation you are having?

When doing ad-hoc distribution of an Android app, you will generally want to pass around a release APK file. At present, Briefcase compiles a debug APK for `briefcase run`, but doesn't produce a release APK, and doesn't put that APK somewhere that is accessible.

### Describe the solution you'd like

`briefcase package android` shoul have a `-p apk` option, which will (a) compile a release APK, and (b) copy that release APK to the `dist` folder.

### Describe alternatives you've considered

1. Only support Play Store distribution
2. Manually build release artefacts by invoking gradle directly
3. Distribute debug artefacts copying from the gradle build file.

### Additional context

In the meantime - if you *do* want an APK, debug APK created by Briefcase as a result of calling `briefcase run android` will be in `build/<app name>/android/gradle/app/build/outputs/apk/debug/app-debug.apk`.

Discussion:

Patch:
diff --git a/changes/1136.misc.rst b/changes/1136.misc.rst
new file mode 100644
index 00000000..082db4e9
--- /dev/null
+++ b/changes/1136.misc.rst
@@ -0,0 +1,1 @@
+Briefcase can now package Android APKs as a packaging artefact.
diff --git a/changes/1361.bugfix.rst b/changes/1361.bugfix.rst
deleted file mode 100644
index 168ebb78..00000000
--- a/changes/1361.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Appimages builds now use ``LINUXDEPLOY_OUTPUT_VERSION`` rather than ``VERSION`` to inject the version number.
diff --git a/changes/1369.bugfix.rst b/changes/1369.bugfix.rst
deleted file mode 100644
index 0bf1aa92..00000000
--- a/changes/1369.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Creating Debian packages no longer fails due to a permission error for certain umask values such as ``0077``.
diff --git a/changes/1375.misc.rst b/changes/1375.misc.rst
deleted file mode 100644
index 32d74bf5..00000000
--- a/changes/1375.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-To avoid spurious warnings, pip's version check warnings were disabled while installing an app's requirements.
diff --git a/docs/reference/platforms/android.rst b/docs/reference/platforms/android.rst
index 4123ddaf..17ac5259 100644
--- a/docs/reference/platforms/android.rst
+++ b/docs/reference/platforms/android.rst
@@ -19,6 +19,13 @@ existing JDK install.
 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.
 
+Briefcase supports three packaging formats for an Android app:
+
+1. An AAB bundle (the default output of ``briefcase package android``, or by using
+   ``briefcase package android -p aab``); or
+2. A Release APK (by using ``briefcase package android -p apk``); or
+3. A Debug APK (by using ``briefcase package android -p debug-apk``).
+
 Icon format
 ===========
 
diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst
index 0fac0857..e547187a 100644
--- a/docs/reference/platforms/macOS/app.rst
+++ b/docs/reference/platforms/macOS/app.rst
@@ -11,9 +11,14 @@ also be compressed to reduce their size for transport.
 
 By default, apps will be both signed and notarized when they are packaged.
 
-The ``.app`` bundle is a distributable artefact. Alternatively, the ``.app``
-bundle can be packaged as a ``.dmg`` that contains the ``.app`` bundle. The
-default packaging format is ``.dmg``.
+Packaging format
+================
+
+Briefcase supports two packaging formats for a macOS ``.app`` bundle:
+
+1. A DMG that contains the ``.app`` bundle (the default output of ``briefcase package
+   macOS``, or by using ``briefcase package macOS -p dmg``); or
+2. A zipped ``.app`` folder (using ``briefcase package macOS -p app``).
 
 Icon format
 ===========
diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst
index 37a81c17..2820184e 100644
--- a/docs/reference/platforms/macOS/xcode.rst
+++ b/docs/reference/platforms/macOS/xcode.rst
@@ -8,9 +8,14 @@ command or directly from Xcode.
 
 By default, apps will be both signed and notarized when they are packaged.
 
-The Xcode project will produce a ``.app`` bundle is a distributable artefact.
-Alternatively, this ``.app`` bundle can be packaged as a ``.dmg`` that contains
-the ``.app`` bundle. The default packaging format is ``.dmg``.
+Packaging format
+================
+
+Briefcase supports two packaging formats for a macOS Xcode project:
+
+1. A DMG that contains the ``.app`` bundle (the default output of ``briefcase package
+   macOS Xcode``, or by using ``briefcase package macOS Xcode -p dmg``); or
+2. A zipped ``.app`` folder (using ``briefcase package macOS Xcode -p app``).
 
 Icon format
 ===========
diff --git a/docs/reference/platforms/windows/app.rst b/docs/reference/platforms/windows/app.rst
index 42e4b1ee..c9696e2a 100644
--- a/docs/reference/platforms/windows/app.rst
+++ b/docs/reference/platforms/windows/app.rst
@@ -3,11 +3,17 @@ Windows App
 ===========
 
 A Windows app is a stub binary, allow with a collection of folders that contain
-the Python code for the app and the Python runtime libraries. Briefcase supports
-two packaging formats for a Windows app:
+the Python code for the app and the Python runtime libraries.
 
-1. As an MSI installer
-2. As a ZIP file containing all files needed to run the app
+Packaging format
+================
+
+Briefcase supports two packaging formats for a Windows app:
+
+1. As an MSI installer (the default output of ``briefcase package windows``, or by using
+   ``briefcase package windows -p msi``); or
+2. As a ZIP file containing all files needed to run the app (by using ``briefcase
+   package windows -p zip``).
 
 Briefcase uses the `WiX Toolset <https://wixtoolset.org/>`__ to build an MSI
 installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is
diff --git a/docs/reference/platforms/windows/visualstudio.rst b/docs/reference/platforms/windows/visualstudio.rst
index a760884e..ae1d4609 100644
--- a/docs/reference/platforms/windows/visualstudio.rst
+++ b/docs/reference/platforms/windows/visualstudio.rst
@@ -28,10 +28,15 @@ ensure that you have installed the following:
   - All default packages
   - C++/CLI support for v143 build tools
 
-Briefcase supports two packaging formats for a Windows App:
+Packaging format
+================
 
-1. As an MSI installer
-2. As a ZIP file containing all files needed to run the app
+Briefcase supports two packaging formats for a Windows app:
+
+1. As an MSI installer (the default output of ``briefcase package windows
+   VisualStudio``, or by using ``briefcase package windows VisualStudio -p msi``); or
+2. As a ZIP file containing all files needed to run the app (by using ``briefcase
+   package windows VisualStudio -p zip``).
 
 Briefcase uses the `WiX Toolset <https://wixtoolset.org/>`__ to build an MSI
 installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index d04e5c66..05ef8705 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -479,8 +479,6 @@ class CreateCommand(BaseCommand):
                             "-m",
                             "pip",
                             "install",
-                            "--disable-pip-version-check",
-                            "--no-python-version-warning",
                             "--upgrade",
                             "--no-user",
                             f"--target={app_packages_path}",
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 4788e198..471172c3 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -64,7 +64,7 @@ class GradleMixin:
 
     @property
     def packaging_formats(self):
-        return ["aab"]
+        return ["aab", "apk", "debug-apk"]
 
     @property
     def default_packaging_format(self):
@@ -76,6 +76,22 @@ class GradleMixin:
     def project_path(self, app):
         return self.bundle_path(app)
 
+    def package_name(self, app) -> Path:
+        package_name_dict = {
+            "aab": Path("bundle") / "release" / "app-release.aab",
+            "apk": Path("apk") / "release" / "app-release-unsigned.apk",
+            "debug-apk": Path("apk") / "debug" / "app-debug.apk",
+        }
+        return package_name_dict[app.packaging_format]
+
+    def build_command(self, app):
+        command_dict = {
+            "aab": "bundleRelease",
+            "apk": "assembleRelease",
+            "debug-apk": "assembleDebug",
+        }
+        return command_dict[app.packaging_format]
+
     def binary_path(self, app):
         return (
             self.bundle_path(app)
@@ -88,7 +104,15 @@ class GradleMixin:
         )
 
     def distribution_path(self, app):
-        return self.dist_path / f"{app.formal_name}-{app.version}.aab"
+        packaging_format_extension = {
+            "aab": "aab",
+            "apk": "apk",
+            "debug-apk": "apk",
+        }
+        return (
+            self.dist_path
+            / f"{app.formal_name}-{app.version}.{packaging_format_extension[app.packaging_format]}"
+        )
 
     def run_gradle(self, app, args):
         # Gradle may install the emulator via the dependency chain build-tools > tools >
@@ -372,7 +396,7 @@ class GradlePackageCommand(GradleMixin, PackageCommand):
         )
         with self.input.wait_bar("Bundling..."):
             try:
-                self.run_gradle(app, ["bundleRelease"])
+                self.run_gradle(app, [self.build_command(app)])
             except subprocess.CalledProcessError as e:
                 raise BriefcaseCommandError("Error while building project.") from e
 
@@ -382,9 +406,7 @@ class GradlePackageCommand(GradleMixin, PackageCommand):
             / "app"
             / "build"
             / "outputs"
-            / "bundle"
-            / "release"
-            / "app-release.aab",
+            / f"{self.package_name(app)}",
             self.distribution_path(app),
         )
 
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index 5af145b2..f9916a5f 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -258,7 +258,7 @@ class LinuxAppImageBuildCommand(LinuxAppImageMixin, BuildCommand):
             try:
                 # For some reason, the version has to be passed in as an
                 # environment variable, *not* in the configuration.
-                env["LINUXDEPLOY_OUTPUT_VERSION"] = app.version
+                env["VERSION"] = app.version
                 # The internals of the binary aren't inherently visible, so
                 # there's no need to package copyright files. These files
                 # appear to be missing by default in the OS dev packages anyway,
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index 2a727084..829f4dab 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -861,8 +861,6 @@ class LinuxSystemPackageCommand(LinuxSystemMixin, PackageCommand):
                 self.tools.shutil.rmtree(DEBIAN_path)
 
             DEBIAN_path.mkdir()
-            # dpkg-dep requires "DEBIAN" directory is >=0755 and <=0775
-            DEBIAN_path.chmod(0o755)
 
             # Add runtime package dependencies. App config has been finalized,
             # so this will be the target-specific definition, if one exists.


Test patch:
diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py
index 20fc232e..97cd61fd 100644
--- a/tests/commands/create/test_install_app_requirements.py
+++ b/tests/commands/create/test_install_app_requirements.py
@@ -123,8 +123,6 @@ def test_app_packages_valid_requires(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -174,8 +172,6 @@ def test_app_packages_valid_requires_no_support_package(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -220,8 +216,6 @@ def test_app_packages_invalid_requires(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -273,8 +267,6 @@ def test_app_packages_offline(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -328,8 +320,6 @@ def test_app_packages_install_requirements(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -393,8 +383,6 @@ def test_app_packages_replace_existing_requirements(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -683,8 +671,6 @@ def test_app_packages_test_requires(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -729,8 +715,6 @@ def test_app_packages_test_requires_test_mode(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
@@ -778,8 +762,6 @@ def test_app_packages_only_test_requires_test_mode(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={app_packages_path}",
diff --git a/tests/platforms/android/gradle/conftest.py b/tests/platforms/android/gradle/conftest.py
index b3b21237..b9de86b0 100644
--- a/tests/platforms/android/gradle/conftest.py
+++ b/tests/platforms/android/gradle/conftest.py
@@ -1,8 +1,38 @@
+import os
+import sys
+from unittest.mock import MagicMock
+
 import pytest
+import requests
+
+from briefcase.console import Console, Log
+from briefcase.integrations.android_sdk import AndroidSDK
+from briefcase.integrations.subprocess import Subprocess
+from briefcase.platforms.android.gradle import GradlePackageCommand
 
 from ....utils import create_file
 
 
+@pytest.fixture
+def package_command(tmp_path, first_app_config):
+    command = GradlePackageCommand(
+        logger=Log(),
+        console=Console(),
+        base_path=tmp_path / "base_path",
+        data_path=tmp_path / "briefcase",
+    )
+    command.tools.android_sdk = MagicMock(spec_set=AndroidSDK)
+    command.tools.os = MagicMock(spec_set=os)
+    command.tools.os.environ = {}
+    command.tools.sys = MagicMock(spec_set=sys)
+    command.tools.requests = MagicMock(spec_set=requests)
+    command.tools.subprocess = MagicMock(spec_set=Subprocess)
+
+    # Make sure the dist folder exists
+    (tmp_path / "base_path" / "dist").mkdir(parents=True)
+    return command
+
+
 @pytest.fixture
 def first_app_generated(first_app_config, tmp_path):
     # Create the briefcase.toml file
diff --git a/tests/platforms/android/gradle/test_package.py b/tests/platforms/android/gradle/test_package.py
index e89ea5c7..0a039c7a 100644
--- a/tests/platforms/android/gradle/test_package.py
+++ b/tests/platforms/android/gradle/test_package.py
@@ -1,142 +1,6 @@
-import os
-import sys
-from subprocess import CalledProcessError
-from unittest.mock import MagicMock
-
-import pytest
-import requests
-
-from briefcase.console import Console, Log
-from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.android_sdk import AndroidSDK
-from briefcase.integrations.subprocess import Subprocess
-from briefcase.platforms.android.gradle import GradlePackageCommand
-
-from ....utils import create_file
-
-
-@pytest.fixture
-def package_command(tmp_path, first_app_config):
-    command = GradlePackageCommand(
-        logger=Log(),
-        console=Console(),
-        base_path=tmp_path / "base_path",
-        data_path=tmp_path / "briefcase",
-    )
-    command.tools.android_sdk = MagicMock(spec_set=AndroidSDK)
-    command.tools.os = MagicMock(spec_set=os)
-    command.tools.os.environ = {}
-    command.tools.sys = MagicMock(spec_set=sys)
-    command.tools.requests = MagicMock(spec_set=requests)
-    command.tools.subprocess = MagicMock(spec_set=Subprocess)
-
-    # Make sure the dist folder exists
-    (tmp_path / "base_path" / "dist").mkdir(parents=True)
-    return command
-
-
-def test_unsupported_template_version(package_command, first_app_generated):
-    """Error raised if template's target version is not supported."""
-    # Mock the build command previously called
-    create_file(package_command.binary_path(first_app_generated), content="")
-
-    package_command.verify_app = MagicMock(wraps=package_command.verify_app)
-
-    package_command._briefcase_toml.update(
-        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
-    )
-
-    with pytest.raises(
-        BriefcaseCommandError,
-        match="The app template used to generate this app is not compatible",
-    ):
-        package_command(first_app_generated, packaging_format="aab")
-
-    package_command.verify_app.assert_called_once_with(first_app_generated)
-
-
 def test_packaging_formats(package_command):
-    assert package_command.packaging_formats == ["aab"]
+    assert package_command.packaging_formats == ["aab", "apk", "debug-apk"]
 
 
 def test_default_packaging_format(package_command):
     assert package_command.default_packaging_format == "aab"
-
-
-def test_distribution_path(package_command, first_app_config, tmp_path):
-    assert (
-        package_command.distribution_path(first_app_config)
-        == tmp_path / "base_path" / "dist" / "First App-0.0.1.aab"
-    )
-
-
-@pytest.mark.parametrize(
-    "host_os,gradlew_name",
-    [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
-)
-def test_execute_gradle(
-    package_command,
-    first_app_config,
-    host_os,
-    gradlew_name,
-    tmp_path,
-):
-    """Validate that package_app() will launch `gradlew bundleRelease` with the
-    appropriate environment & cwd, and that it will use `gradlew.bat` on Windows but
-    `gradlew` elsewhere."""
-    # Mock out `host_os` so we can validate which name is used for gradlew.
-    package_command.tools.host_os = host_os
-
-    # Set up a side effect of invoking gradle to create a bundle
-    def create_bundle(*args, **kwargs):
-        create_file(
-            tmp_path
-            / "base_path"
-            / "build"
-            / "first-app"
-            / "android"
-            / "gradle"
-            / "app"
-            / "build"
-            / "outputs"
-            / "bundle"
-            / "release"
-            / "app-release.aab",
-            "Android release",
-        )
-
-    package_command.tools.subprocess.run.side_effect = create_bundle
-
-    # Create mock environment with `key`, which we expect to be preserved, and
-    # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
-    package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
-
-    package_command.package_app(first_app_config)
-
-    package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
-    package_command.tools.subprocess.run.assert_called_once_with(
-        [
-            package_command.bundle_path(first_app_config) / gradlew_name,
-            "bundleRelease",
-            "--console",
-            "plain",
-        ],
-        cwd=package_command.bundle_path(first_app_config),
-        env=package_command.tools.android_sdk.env,
-        check=True,
-    )
-
-    # The release asset has been moved into the dist folder
-    assert (tmp_path / "base_path" / "dist" / "First App-0.0.1.aab").exists()
-
-
-def test_print_gradle_errors(package_command, first_app_config):
-    """Validate that package_app() will convert stderr/stdout from the process into
-    exception text."""
-    # Create a mock subprocess that crashes, printing text partly in non-ASCII.
-    package_command.tools.subprocess.run.side_effect = CalledProcessError(
-        returncode=1,
-        cmd=["ignored"],
-    )
-    with pytest.raises(BriefcaseCommandError):
-        package_command.package_app(first_app_config)
diff --git a/tests/platforms/android/gradle/test_package__aab.py b/tests/platforms/android/gradle/test_package__aab.py
new file mode 100644
index 00000000..5cbee423
--- /dev/null
+++ b/tests/platforms/android/gradle/test_package__aab.py
@@ -0,0 +1,113 @@
+from subprocess import CalledProcessError
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ....utils import create_file
+
+
+@pytest.fixture
+def first_app_aab(first_app_config):
+    first_app_config.packaging_format = "aab"
+    return first_app_config
+
+
+def test_unsupported_template_version(package_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    # Mock the build command previously called
+    create_file(package_command.binary_path(first_app_generated), content="")
+
+    package_command.verify_app = MagicMock(wraps=package_command.verify_app)
+
+    package_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        package_command(first_app_generated, packaging_format="aab")
+
+    package_command.verify_app.assert_called_once_with(first_app_generated)
+
+
+def test_distribution_path(package_command, first_app_aab, tmp_path):
+    assert (
+        package_command.distribution_path(first_app_aab)
+        == tmp_path / "base_path" / "dist" / "First App-0.0.1.aab"
+    )
+
+
+@pytest.mark.parametrize(
+    "host_os,gradlew_name",
+    [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
+)
+def test_execute_gradle(
+    package_command,
+    first_app_aab,
+    host_os,
+    gradlew_name,
+    tmp_path,
+):
+    """Validate that package_app() will launch `gradlew bundleRelease` with the
+    appropriate environment & cwd, and that it will use `gradlew.bat` on Windows but
+    `gradlew` elsewhere."""
+    # Mock out `host_os` so we can validate which name is used for gradlew.
+    package_command.tools.host_os = host_os
+
+    # Set up a side effect of invoking gradle to create a bundle
+    def create_bundle(*args, **kwargs):
+        create_file(
+            tmp_path
+            / "base_path"
+            / "build"
+            / "first-app"
+            / "android"
+            / "gradle"
+            / "app"
+            / "build"
+            / "outputs"
+            / "bundle"
+            / "release"
+            / "app-release.aab",
+            "Android release",
+        )
+
+    package_command.tools.subprocess.run.side_effect = create_bundle
+
+    # Create mock environment with `key`, which we expect to be preserved, and
+    # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
+    package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
+
+    package_command.package_app(first_app_aab)
+
+    package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            package_command.bundle_path(first_app_aab) / gradlew_name,
+            "bundleRelease",
+            "--console",
+            "plain",
+        ],
+        cwd=package_command.bundle_path(first_app_aab),
+        env=package_command.tools.android_sdk.env,
+        check=True,
+    )
+
+    # The release asset has been moved into the dist folder
+    assert (tmp_path / "base_path" / "dist" / "First App-0.0.1.aab").exists()
+
+
+def test_print_gradle_errors(package_command, first_app_aab):
+    """Validate that package_app() will convert stderr/stdout from the process into
+    exception text."""
+    # Create a mock subprocess that crashes, printing text partly in non-ASCII.
+    package_command.tools.subprocess.run.side_effect = CalledProcessError(
+        returncode=1,
+        cmd=["ignored"],
+    )
+    with pytest.raises(BriefcaseCommandError):
+        package_command.package_app(first_app_aab)
diff --git a/tests/platforms/android/gradle/test_package__apk.py b/tests/platforms/android/gradle/test_package__apk.py
new file mode 100644
index 00000000..9614e267
--- /dev/null
+++ b/tests/platforms/android/gradle/test_package__apk.py
@@ -0,0 +1,114 @@
+from subprocess import CalledProcessError
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ....utils import create_file
+
+
+@pytest.fixture
+def first_app_apk(first_app_config):
+    first_app_config.packaging_format = "apk"
+    return first_app_config
+
+
+def test_unsupported_template_version(package_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    # Mock the build command previously called
+    create_file(package_command.binary_path(first_app_generated), content="")
+
+    package_command.verify_app = MagicMock(wraps=package_command.verify_app)
+
+    package_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        package_command(first_app_generated, packaging_format="apk")
+
+    package_command.verify_app.assert_called_once_with(first_app_generated)
+
+
+def test_distribution_path(package_command, first_app_apk, tmp_path):
+    print(package_command.packaging_formats)
+    assert (
+        package_command.distribution_path(first_app_apk)
+        == tmp_path / "base_path" / "dist" / "First App-0.0.1.apk"
+    )
+
+
+@pytest.mark.parametrize(
+    "host_os,gradlew_name",
+    [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
+)
+def test_execute_gradle(
+    package_command,
+    first_app_apk,
+    host_os,
+    gradlew_name,
+    tmp_path,
+):
+    """Validate that package_app() will launch `gradlew bundleRelease` with the
+    appropriate environment & cwd, and that it will use `gradlew.bat` on Windows but
+    `gradlew` elsewhere."""
+    # Mock out `host_os` so we can validate which name is used for gradlew.
+    package_command.tools.host_os = host_os
+
+    # Set up a side effect of invoking gradle to create a bundle
+    def create_bundle(*args, **kwargs):
+        create_file(
+            tmp_path
+            / "base_path"
+            / "build"
+            / "first-app"
+            / "android"
+            / "gradle"
+            / "app"
+            / "build"
+            / "outputs"
+            / "apk"
+            / "release"
+            / "app-release-unsigned.apk",
+            "Android release",
+        )
+
+    package_command.tools.subprocess.run.side_effect = create_bundle
+
+    # Create mock environment with `key`, which we expect to be preserved, and
+    # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
+    package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
+
+    package_command.package_app(first_app_apk)
+
+    package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            package_command.bundle_path(first_app_apk) / gradlew_name,
+            "assembleRelease",
+            "--console",
+            "plain",
+        ],
+        cwd=package_command.bundle_path(first_app_apk),
+        env=package_command.tools.android_sdk.env,
+        check=True,
+    )
+
+    # The release asset has been moved into the dist folder
+    assert (tmp_path / "base_path" / "dist" / "First App-0.0.1.apk").exists()
+
+
+def test_print_gradle_errors(package_command, first_app_apk):
+    """Validate that package_app() will convert stderr/stdout from the process into
+    exception text."""
+    # Create a mock subprocess that crashes, printing text partly in non-ASCII.
+    package_command.tools.subprocess.run.side_effect = CalledProcessError(
+        returncode=1,
+        cmd=["ignored"],
+    )
+    with pytest.raises(BriefcaseCommandError):
+        package_command.package_app(first_app_apk)
diff --git a/tests/platforms/android/gradle/test_package__debug_apk.py b/tests/platforms/android/gradle/test_package__debug_apk.py
new file mode 100644
index 00000000..aeb9de34
--- /dev/null
+++ b/tests/platforms/android/gradle/test_package__debug_apk.py
@@ -0,0 +1,114 @@
+from subprocess import CalledProcessError
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ....utils import create_file
+
+
+@pytest.fixture
+def first_app_apk(first_app_config):
+    first_app_config.packaging_format = "debug-apk"
+    return first_app_config
+
+
+def test_unsupported_template_version(package_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    # Mock the build command previously called
+    create_file(package_command.binary_path(first_app_generated), content="")
+
+    package_command.verify_app = MagicMock(wraps=package_command.verify_app)
+
+    package_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        package_command(first_app_generated, packaging_format="debug-apk")
+
+    package_command.verify_app.assert_called_once_with(first_app_generated)
+
+
+def test_distribution_path(package_command, first_app_apk, tmp_path):
+    print(package_command.packaging_formats)
+    assert (
+        package_command.distribution_path(first_app_apk)
+        == tmp_path / "base_path" / "dist" / "First App-0.0.1.apk"
+    )
+
+
+@pytest.mark.parametrize(
+    "host_os,gradlew_name",
+    [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
+)
+def test_execute_gradle(
+    package_command,
+    first_app_apk,
+    host_os,
+    gradlew_name,
+    tmp_path,
+):
+    """Validate that package_app() will launch `gradlew bundleRelease` with the
+    appropriate environment & cwd, and that it will use `gradlew.bat` on Windows but
+    `gradlew` elsewhere."""
+    # Mock out `host_os` so we can validate which name is used for gradlew.
+    package_command.tools.host_os = host_os
+
+    # Set up a side effect of invoking gradle to create a bundle
+    def create_bundle(*args, **kwargs):
+        create_file(
+            tmp_path
+            / "base_path"
+            / "build"
+            / "first-app"
+            / "android"
+            / "gradle"
+            / "app"
+            / "build"
+            / "outputs"
+            / "apk"
+            / "debug"
+            / "app-debug.apk",
+            "Android release",
+        )
+
+    package_command.tools.subprocess.run.side_effect = create_bundle
+
+    # Create mock environment with `key`, which we expect to be preserved, and
+    # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
+    package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
+
+    package_command.package_app(first_app_apk)
+
+    package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            package_command.bundle_path(first_app_apk) / gradlew_name,
+            "assembleDebug",
+            "--console",
+            "plain",
+        ],
+        cwd=package_command.bundle_path(first_app_apk),
+        env=package_command.tools.android_sdk.env,
+        check=True,
+    )
+
+    # The release asset has been moved into the dist folder
+    assert (tmp_path / "base_path" / "dist" / "First App-0.0.1.apk").exists()
+
+
+def test_print_gradle_errors(package_command, first_app_apk):
+    """Validate that package_app() will convert stderr/stdout from the process into
+    exception text."""
+    # Create a mock subprocess that crashes, printing text partly in non-ASCII.
+    package_command.tools.subprocess.run.side_effect = CalledProcessError(
+        returncode=1,
+        cmd=["ignored"],
+    )
+    with pytest.raises(BriefcaseCommandError):
+        package_command.package_app(first_app_apk)
diff --git a/tests/platforms/iOS/xcode/test_create.py b/tests/platforms/iOS/xcode/test_create.py
index 036614a2..38ca71b4 100644
--- a/tests/platforms/iOS/xcode/test_create.py
+++ b/tests/platforms/iOS/xcode/test_create.py
@@ -48,8 +48,6 @@ def test_extra_pip_args(create_command, first_app_generated, tmp_path):
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={tmp_path / 'base_path' / 'build' / 'first-app' / 'ios' / 'xcode' / 'app_packages'}",
diff --git a/tests/platforms/iOS/xcode/test_update.py b/tests/platforms/iOS/xcode/test_update.py
index 33c26051..8d0a3802 100644
--- a/tests/platforms/iOS/xcode/test_update.py
+++ b/tests/platforms/iOS/xcode/test_update.py
@@ -35,8 +35,6 @@ def test_extra_pip_args(update_command, first_app_generated, tmp_path):
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={tmp_path / 'base_path' / 'build' / 'first-app' / 'ios' / 'xcode' / 'app_packages'}",
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index 617cf9f0..c2d78db5 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -186,7 +186,7 @@ def test_build_appimage(build_command, first_app, tmp_path, sub_stream_kw):
         ],
         env={
             "PATH": "/usr/local/bin:/usr/bin:/path/to/somewhere",
-            "LINUXDEPLOY_OUTPUT_VERSION": "0.0.1",
+            "VERSION": "0.0.1",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT": "1",
             "APPIMAGE_EXTRACT_AND_RUN": "1",
             "ARCH": "wonky",
@@ -276,7 +276,7 @@ def test_build_appimage_with_plugin(build_command, first_app, tmp_path, sub_stre
         env={
             "PATH": f"{gtk_plugin_path.parent}:{app_dir.parent}:/usr/local/bin:/usr/bin:/path/to/somewhere",
             "DEPLOY_GTK_VERSION": "3",
-            "LINUXDEPLOY_OUTPUT_VERSION": "0.0.1",
+            "VERSION": "0.0.1",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT": "1",
             "APPIMAGE_EXTRACT_AND_RUN": "1",
             "ARCH": "wonky",
@@ -353,7 +353,7 @@ def test_build_failure(build_command, first_app, tmp_path, sub_stream_kw):
         ],
         env={
             "PATH": "/usr/local/bin:/usr/bin:/path/to/somewhere",
-            "LINUXDEPLOY_OUTPUT_VERSION": "0.0.1",
+            "VERSION": "0.0.1",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT": "1",
             "APPIMAGE_EXTRACT_AND_RUN": "1",
             "ARCH": "wonky",
@@ -416,7 +416,7 @@ def test_build_appimage_in_docker(build_command, first_app, tmp_path, sub_stream
             "--volume",
             f"{build_command.data_path}:/home/brutus/.cache/briefcase:z",
             "--env",
-            "LINUXDEPLOY_OUTPUT_VERSION=0.0.1",
+            "VERSION=0.0.1",
             "--env",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT=1",
             "--env",
@@ -540,7 +540,7 @@ def test_build_appimage_with_plugins_in_docker(
                 ":/app:/docker/bin:/docker/sbin"
             ),
             "--env",
-            "LINUXDEPLOY_OUTPUT_VERSION=0.0.1",
+            "VERSION=0.0.1",
             "--env",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT=1",
             "--env",
@@ -671,7 +671,7 @@ def test_build_appimage_with_support_package_update(
         ],
         env={
             "PATH": "/usr/local/bin:/usr/bin:/path/to/somewhere",
-            "LINUXDEPLOY_OUTPUT_VERSION": "0.0.1",
+            "VERSION": "0.0.1",
             "DISABLE_COPYRIGHT_FILES_DEPLOYMENT": "1",
             "APPIMAGE_EXTRACT_AND_RUN": "1",
             "ARCH": "wonky",
diff --git a/tests/platforms/linux/test_LocalRequirementsMixin.py b/tests/platforms/linux/test_LocalRequirementsMixin.py
index 8de76c1e..50e4887c 100644
--- a/tests/platforms/linux/test_LocalRequirementsMixin.py
+++ b/tests/platforms/linux/test_LocalRequirementsMixin.py
@@ -177,8 +177,6 @@ def test_install_app_requirements_in_docker(create_command, first_app_config, tm
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             "--target=/app/path/to/app_packages",
@@ -232,8 +230,6 @@ def test_install_app_requirements_no_docker(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             f"--target={tmp_path}/base_path/build/first-app/tester/dummy/path/to/app_packages",
@@ -345,8 +341,6 @@ def test_install_app_requirements_with_locals(
             "-m",
             "pip",
             "install",
-            "--disable-pip-version-check",
-            "--no-python-version-warning",
             "--upgrade",
             "--no-user",
             "--target=/app/path/to/app_packages",