diff --git a/changes/1136.misc.rst b/changes/1136.misc.rst new file mode 100644 index 000000000..082db4e98 --- /dev/null +++ b/changes/1136.misc.rst @@ -0,0 +1 @@ +Briefcase can now package Android APKs as a packaging artefact. diff --git a/docs/reference/platforms/android.rst b/docs/reference/platforms/android.rst index 4123ddafd..17ac5259e 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 0fac08574..e547187ab 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 37a81c174..2820184e9 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 42e4b1ee0..c9696e2ad 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 `__ 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 a760884e7..ae1d46092 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 `__ to build an MSI installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index 4788e198f..471172c3a 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 @@ def __init__(self, *args, **kwargs): 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 @@ def binary_path(self, app): ) 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 @@ def package_app(self, app: BaseConfig, **kwargs): ) 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 @@ def package_app(self, app: BaseConfig, **kwargs): / "app" / "build" / "outputs" - / "bundle" - / "release" - / "app-release.aab", + / f"{self.package_name(app)}", self.distribution_path(app), ) diff --git a/tests/platforms/android/gradle/conftest.py b/tests/platforms/android/gradle/conftest.py index b3b212374..b9de86b07 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 e89ea5c78..0a039c7a2 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 000000000..5cbee4231 --- /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 000000000..9614e267b --- /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 000000000..aeb9de34b --- /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)