diff --git a/changes/2351.feature.md b/changes/2351.feature.md new file mode 100644 index 000000000..ab10bf3b9 --- /dev/null +++ b/changes/2351.feature.md @@ -0,0 +1 @@ +Added a `--debug` option to the `build` and `run` commands for Android. diff --git a/docs/en/how-to/debugging/pdb.md b/docs/en/how-to/debugging/pdb.md index 7e2fe642f..4668aec1b 100644 --- a/docs/en/how-to/debugging/pdb.md +++ b/docs/en/how-to/debugging/pdb.md @@ -10,13 +10,13 @@ To debug an app in development mode, add `breakpoint()` to your code somewhere t /// warning | Note -This is currently an **experimental feature** that is only supported on Windows, macOS and iOS. +This is currently an **experimental feature** that is only supported on Windows, macOS, iOS and Android. /// To debug a bundled app, add `breakpoint()` somewhere in your code where the debugger should halt. -Your app must then be modified to include a bootstrap that will connect to the VS Code debugger. This is done by passing the `--debug debugpy` option to `briefcase build`: +Your app must then be modified to include a bootstrap that will connect to the VS Code debugger. This is done by passing the `--debug pdb` option to `briefcase build`: ```console $ briefcase build --debug pdb diff --git a/docs/en/how-to/debugging/vscode.md b/docs/en/how-to/debugging/vscode.md index 49fecb915..5d34fb7ee 100644 --- a/docs/en/how-to/debugging/vscode.md +++ b/docs/en/how-to/debugging/vscode.md @@ -40,7 +40,7 @@ To start a debug session, open the debug view in VS Code using the sidebar, sele /// warning | Experimental feature -This is currently an **experimental feature** that is only supported on Windows, macOS and iOS. +This is currently an **experimental feature** that is only supported on Windows, macOS, iOS and Android. /// diff --git a/docs/en/reference/commands/build.md b/docs/en/reference/commands/build.md index 25863ff4a..b2d1ceea2 100644 --- a/docs/en/reference/commands/build.md +++ b/docs/en/reference/commands/build.md @@ -105,7 +105,7 @@ Currently the following debuggers are supported: If calling only `--debug` without selecting a debugger explicitly, `pdb` is used as default. -This is an **experimental** new feature, that is currently only supported on Windows, macOS and iOS. +This is an **experimental** new feature, that is currently only supported on Windows, macOS, iOS and Android. This option may slow down the app a little bit. diff --git a/docs/en/reference/commands/run.md b/docs/en/reference/commands/run.md index 669ba9a11..aab0f5a77 100644 --- a/docs/en/reference/commands/run.md +++ b/docs/en/reference/commands/run.md @@ -109,7 +109,7 @@ Currently the following debuggers are supported: If calling only `--debug` without selecting a debugger explicitly, `pdb` is used as default. -This is an **experimental** new feature, that is currently only supported on Windows, macOS and iOS. +This is an **experimental** new feature, that is currently only supported on Windows, macOS, iOS and Android. The selected debugger in `run --debug ` has to match the selected debugger in `build --debug `. diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 0375f0f41..6966a5b4d 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -1523,7 +1523,9 @@ def force_stop_app(self, package: str): f"Unable to force stop app {package} on {self.device}" ) from e - def start_app(self, package: str, activity: str, passthrough: list[str]): + def start_app( + self, package: str, activity: str, passthrough: list[str], env: dict[str, str] + ): """Start an app, specified as a package name & activity name. If you have an APK file, and you are not sure of the package or activity @@ -1533,6 +1535,7 @@ def start_app(self, package: str, activity: str, passthrough: list[str]): :param package: The name of the Android package, e.g., com.username.myapp. :param activity: The activity of the APK to start. :param passthrough: Arguments to pass to the app. + :param env: Environment variables to pass to the app. :returns: `None` on success; raises an exception on failure. """ try: @@ -1552,6 +1555,15 @@ def start_app(self, package: str, activity: str, passthrough: list[str]): "--es", "org.beeware.ARGV", shlex.quote(json.dumps(passthrough)), # Protect from Android's shell + *( + [ + "--es", + "org.beeware.ENVIRON", + shlex.quote(json.dumps(env)), # Protect from Android's shell + ] + if env + else [] + ), ) # `adb shell am start` always exits with status zero. We look for error diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index 06b3c7829..f8fab4356 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -19,6 +19,10 @@ ) from briefcase.config import AppConfig, parsed_version from briefcase.console import ANSI_ESC_SEQ_RE_DEF +from briefcase.debuggers.base import ( + AppPackagesPathMappings, + DebuggerConnectionMode, +) from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.android_sdk import ADB, AndroidSDK from briefcase.integrations.subprocess import SubprocessArgT @@ -69,7 +73,7 @@ def android_log_clean_filter(line): class GradleMixin: output_format = "gradle" platform = "android" - platform_target_version = "0.3.15" + platform_target_version = "0.3.27" @property def packaging_formats(self): @@ -219,13 +223,6 @@ def output_format_template_context(self, app: AppConfig): return { "version_code": version_code, "safe_formal_name": safe_formal_name(app.formal_name), - # Extract test packages to enable features like test discovery and assertion - # rewriting. - "extract_packages": ", ".join( - f'"{name}"' - for path in (app.test_sources or []) - if (name := Path(path).name) - ), "build_gradle_dependencies": {"implementation": dependencies}, } @@ -314,6 +311,7 @@ def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): class GradleUpdateCommand(GradleCreateCommand, UpdateCommand): description = "Update an existing Android Gradle project." + supports_debugger = True class GradleOpenCommand(GradleMixin, OpenCommand): @@ -322,10 +320,14 @@ class GradleOpenCommand(GradleMixin, OpenCommand): class GradleBuildCommand(GradleMixin, BuildCommand): description = "Build an Android debug APK." + supports_debugger = True def metadata_resource_path(self, app: AppConfig): return self.bundle_path(app) / self.path_index(app, "metadata_resource_path") + def extract_packages_path(self, app: AppConfig): + return self.bundle_path(app) / self.path_index(app, "extract_packages_path") + def update_app_metadata(self, app: AppConfig): with ( self.console.wait_bar("Setting main module..."), @@ -341,6 +343,25 @@ def update_app_metadata(self, app: AppConfig): """ ) + with ( + self.console.wait_bar("Setting packages to extract..."), + self.extract_packages_path(app).open("w", encoding="utf-8") as f, + ): + if app.debugger: + # In debug mode include the .py files and extract all of them so + # that the debugger can get the source code at runtime. This is + # e.g. necessary for setting breakpoints in VS Code. + extract_packages = ["*"] + else: + # Extract test packages, to enable features like test discovery and + # assertion rewriting. + extract_sources = app.test_sources or [] + extract_packages = [ + name for path in extract_sources if (name := Path(path).name) + ] + + f.write("\n".join(extract_packages)) + def build_app(self, app: AppConfig, **kwargs): """Build an application. @@ -359,6 +380,7 @@ def build_app(self, app: AppConfig, **kwargs): class GradleRunCommand(GradleMixin, RunCommand): description = "Run an Android debug APK on a device (physical or virtual)." + supports_debugger = True def verify_tools(self): super().verify_tools() @@ -404,6 +426,20 @@ def add_options(self, parser): help="Reverse the specified port from device to host.", ) + def debugger_app_packages_path_mapping( + self, app: AppConfig + ) -> AppPackagesPathMappings: + """Get the path mappings for the app packages. + + :param app: The config object for the app + :returns: The path mappings for the app packages + """ + app_packages_path = self.bundle_path(app) / "app/build/python/pip/debug/common" + return AppPackagesPathMappings( + sys_path_regex="requirements$", + host_folder=f"{app_packages_path}", + ) + def run_app( self, app: AppConfig, @@ -476,6 +512,17 @@ def run_app( forward_ports = forward_ports or [] reverse_ports = reverse_ports or [] + env = {} + if self.console.is_debug: + env["BRIEFCASE_DEBUG"] = "1" + + if app.debugger: + env["BRIEFCASE_DEBUGGER"] = app.debugger.get_env_config(self, app) + if app.debugger.connection_mode == DebuggerConnectionMode.SERVER: + forward_ports.append(app.debugger_port) + else: + reverse_ports.append(app.debugger_port) + # Forward/Reverse requested ports with self.forward_ports(adb, forward_ports, reverse_ports): # To start the app, we launch `org.beeware.android.MainActivity`. @@ -484,7 +531,7 @@ def run_app( device_start_time = adb.datetime() adb.start_app( - package, "org.beeware.android.MainActivity", passthrough + package, "org.beeware.android.MainActivity", passthrough, env ) # Try to get the PID for 5 seconds. diff --git a/tests/integrations/android_sdk/ADB/test_start_app.py b/tests/integrations/android_sdk/ADB/test_start_app.py index 17788a0dc..1c6a0d525 100644 --- a/tests/integrations/android_sdk/ADB/test_start_app.py +++ b/tests/integrations/android_sdk/ADB/test_start_app.py @@ -30,7 +30,7 @@ def test_start_app_launches_app(adb, capsys, passthrough): # Invoke start_app adb.start_app( - "com.example.sample.package", "com.example.sample.activity", passthrough + "com.example.sample.package", "com.example.sample.activity", passthrough, {} ) # Validate call parameters. @@ -54,6 +54,45 @@ def test_start_app_launches_app(adb, capsys, passthrough): assert "normal adb output" not in capsys.readouterr() +@pytest.mark.parametrize( + "env", + [ + {"PARAM1": "VALUE1"}, + {"BRIEFCASE_DEBUGGER": '{"host": "localhost", "port": 1234}'}, + ], +) +def test_start_app_launches_app_with_env(adb, capsys, env): + """Invoking `start_app()` calls `run()` with the appropriate parameters.""" + # Mock out the run command on an adb instance + adb.run = MagicMock(return_value="example normal adb output") + + # Invoke start_app + adb.start_app("com.example.sample.package", "com.example.sample.activity", [], env) + + # Validate call parameters. + adb.run.assert_called_once_with( + "shell", + "am", + "start", + "-n", + "com.example.sample.package/com.example.sample.activity", + "-a", + "android.intent.action.MAIN", + "-c", + "android.intent.category.LAUNCHER", + "--es", + "org.beeware.ARGV", + "'[]'", + "--es", + "org.beeware.ENVIRON", + shlex.quote(json.dumps(env)), + ) + + # Validate that the normal output of the command was not printed (since there + # was no error). + assert "normal adb output" not in capsys.readouterr() + + def test_missing_activity(adb): """If the activity doesn't exist, the error is caught.""" # Use real `adb` output from launching an activity that does not exist. @@ -69,7 +108,9 @@ def test_missing_activity(adb): ) with pytest.raises(BriefcaseCommandError) as exc_info: - adb.start_app("com.example.sample.package", "com.example.sample.activity", []) + adb.start_app( + "com.example.sample.package", "com.example.sample.activity", [], {} + ) assert "Activity class not found" in str(exc_info.value) @@ -81,7 +122,9 @@ def test_invalid_device(adb): adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice")) with pytest.raises(InvalidDeviceError): - adb.start_app("com.example.sample.package", "com.example.sample.activity", []) + adb.start_app( + "com.example.sample.package", "com.example.sample.activity", [], {} + ) def test_unable_to_start(adb): @@ -92,4 +135,6 @@ def test_unable_to_start(adb): BriefcaseCommandError, match=r"Unable to start com.example.sample.package/com.example.sample.activity on exampleDevice", ): - adb.start_app("com.example.sample.package", "com.example.sample.activity", []) + adb.start_app( + "com.example.sample.package", "com.example.sample.activity", [], {} + ) diff --git a/tests/platforms/android/gradle/conftest.py b/tests/platforms/android/gradle/conftest.py index cef53de31..6b72b1db7 100644 --- a/tests/platforms/android/gradle/conftest.py +++ b/tests/platforms/android/gradle/conftest.py @@ -50,6 +50,7 @@ def first_app_generated(first_app_config, tmp_path): app_packages_path="app_packages" support_path="support" metadata_resource_path="res/briefcase.xml" +extract_packages_path = "app/extract-packages.txt" """, ) @@ -64,4 +65,16 @@ def first_app_generated(first_app_config, tmp_path): / "briefcase.xml", """""", ) + + create_file( + tmp_path + / "base_path" + / "build" + / "first-app" + / "android" + / "gradle" + / "app" + / "extract-packages.txt", + "something-to-be-overwritten", + ) return first_app_config diff --git a/tests/platforms/android/gradle/test_build.py b/tests/platforms/android/gradle/test_build.py index c118701ae..1e8b2bc68 100644 --- a/tests/platforms/android/gradle/test_build.py +++ b/tests/platforms/android/gradle/test_build.py @@ -37,7 +37,7 @@ def test_unsupported_template_version(build_command, first_app_generated): build_command.verify_app = MagicMock(wraps=build_command.verify_app) build_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( @@ -115,6 +115,18 @@ def test_build_app( + "\n" ) + with ( + tmp_path + / "base_path" + / "build" + / "first-app" + / "android" + / "gradle" + / "app" + / "extract-packages.txt" + ).open(encoding="utf-8") as f: + assert f.read() == "" + @pytest.mark.parametrize( ("host_os", "gradlew_name", "debug_mode"), @@ -135,6 +147,7 @@ def test_build_app_test_mode( ): """The app can be built in test mode, invoking gradle and rewriting app metadata.""" first_app_generated.test_mode = True + first_app_generated.test_sources = ["my_test_package"] # Mock out `host_os` so we can validate which name is used for gradlew. build_command.tools.host_os = host_os @@ -184,6 +197,97 @@ def test_build_app_test_mode( + "\n" ) + with ( + tmp_path + / "base_path" + / "build" + / "first-app" + / "android" + / "gradle" + / "app" + / "extract-packages.txt" + ).open(encoding="utf-8") as f: + assert f.read() == "my_test_package" + + +extract_packages_params = [ + ([], ""), + ([""], ""), + (["one"], "one"), + (["one/two"], "two"), + (["one//two"], "two"), + (["one/two/three"], "three"), + (["one", "two"], "one\ntwo"), + (["one", "two", "three"], "one\ntwo\nthree"), + (["one/two", "three/four"], "two\nfour"), + (["/leading"], "leading"), + (["/leading/two"], "two"), + (["/leading/two/three"], "three"), + (["trailing/"], "trailing"), + (["trailing//"], "trailing"), + (["trailing/two/"], "two"), +] + +# Handle differences in UNC path parsing (https://github.com/python/cpython/pull/100351). +extract_packages_params += [ + ( + ["//leading"], + "" if sys.platform == "win32" and sys.version_info >= (3, 12) else "leading", + ), + ( + ["//leading/two"], + "" if sys.platform == "win32" else "two", + ), + (["//leading/two/three"], "three"), + (["//leading/two/three/four"], "four"), +] + +if sys.platform == "win32": + extract_packages_params += [ + ([path.replace("/", "\\") for path in test_sources], expected) + for test_sources, expected in extract_packages_params + ] + + +@pytest.mark.parametrize(("test_sources", "expected"), extract_packages_params) +def test_extract_packages( + build_command, first_app_generated, test_sources, expected, tmp_path +): + first_app_generated.test_sources = test_sources + build_command.update_app_metadata(first_app_generated) + + with ( + tmp_path + / "base_path" + / "build" + / "first-app" + / "android" + / "gradle" + / "app" + / "extract-packages.txt" + ).open(encoding="utf-8") as f: + assert f.read() == expected + + +def test_extract_packages_debugger( + build_command, first_app_generated, dummy_debugger, tmp_path +): + first_app_generated.test_sources = ["one", "two", "three"] + first_app_generated.debugger = dummy_debugger + build_command.update_app_metadata(first_app_generated) + + with ( + tmp_path + / "base_path" + / "build" + / "first-app" + / "android" + / "gradle" + / "app" + / "extract-packages.txt" + ).open(encoding="utf-8") as f: + assert f.read() == "*" + def test_print_gradle_errors(build_command, first_app_generated): """Validate that build_app() will convert stderr/stdout from the process into diff --git a/tests/platforms/android/gradle/test_create.py b/tests/platforms/android/gradle/test_create.py index 59fdfe5ca..44832e213 100644 --- a/tests/platforms/android/gradle/test_create.py +++ b/tests/platforms/android/gradle/test_create.py @@ -38,7 +38,7 @@ def test_unsupported_template_version(create_command, first_app_config): create_command.verify_app = MagicMock(wraps=create_command.verify_app) create_command._briefcase_toml.update( - {first_app_config: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_config: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( @@ -154,52 +154,6 @@ def test_build_gradle_dependencies( ) == has_warning -extract_packages_params = [ - ([], ""), - ([""], ""), - (["one"], '"one"'), - (["one/two"], '"two"'), - (["one//two"], '"two"'), - (["one/two/three"], '"three"'), - (["one", "two"], '"one", "two"'), - (["one", "two", "three"], '"one", "two", "three"'), - (["one/two", "three/four"], '"two", "four"'), - (["/leading"], '"leading"'), - (["/leading/two"], '"two"'), - (["/leading/two/three"], '"three"'), - (["trailing/"], '"trailing"'), - (["trailing//"], '"trailing"'), - (["trailing/two/"], '"two"'), -] - -# Handle differences in UNC path parsing (https://github.com/python/cpython/pull/100351). -extract_packages_params += [ - ( - ["//leading"], - "" if sys.platform == "win32" and sys.version_info >= (3, 12) else '"leading"', - ), - ( - ["//leading/two"], - "" if sys.platform == "win32" else '"two"', - ), - (["//leading/two/three"], '"three"'), - (["//leading/two/three/four"], '"four"'), -] - -if sys.platform == "win32": - extract_packages_params += [ - ([path.replace("/", "\\") for path in test_sources], expected) - for test_sources, expected in extract_packages_params - ] - - -@pytest.mark.parametrize(("test_sources", "expected"), extract_packages_params) -def test_extract_packages(create_command, first_app_config, test_sources, expected): - first_app_config.test_sources = test_sources - context = create_command.output_format_template_context(first_app_config) - assert context["extract_packages"] == expected - - @pytest.mark.parametrize( ("permissions", "features", "context"), [ diff --git a/tests/platforms/android/gradle/test_open.py b/tests/platforms/android/gradle/test_open.py index eb1a14a29..94f4e5abb 100644 --- a/tests/platforms/android/gradle/test_open.py +++ b/tests/platforms/android/gradle/test_open.py @@ -39,9 +39,9 @@ def open_command(dummy_console, tmp_path, first_app_config): command.tools.subprocess = MagicMock(spec_set=Subprocess) command.tools.file.download = MagicMock(spec_set=File.download) - # Mock all apps as targeting version 0.3.15 + # Mock all apps as targeting version 0.3.27 command._briefcase_toml = defaultdict( - lambda: {"briefcase": {"target_version": "0.3.15"}} + lambda: {"briefcase": {"target_version": "0.3.27"}} ) # Mock some OS calls needed to make the tools appear to exist @@ -70,7 +70,7 @@ def test_unsupported_template_version(open_command, first_app_generated, tmp_pat open_command.verify_app = MagicMock(wraps=open_command.verify_app) open_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( diff --git a/tests/platforms/android/gradle/test_package__aab.py b/tests/platforms/android/gradle/test_package__aab.py index 07a737b40..85ed717ca 100644 --- a/tests/platforms/android/gradle/test_package__aab.py +++ b/tests/platforms/android/gradle/test_package__aab.py @@ -23,7 +23,7 @@ def test_unsupported_template_version(package_command, first_app_generated): package_command.verify_app = MagicMock(wraps=package_command.verify_app) package_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( diff --git a/tests/platforms/android/gradle/test_package__apk.py b/tests/platforms/android/gradle/test_package__apk.py index b3944ea99..48d0919a3 100644 --- a/tests/platforms/android/gradle/test_package__apk.py +++ b/tests/platforms/android/gradle/test_package__apk.py @@ -23,7 +23,7 @@ def test_unsupported_template_version(package_command, first_app_generated): package_command.verify_app = MagicMock(wraps=package_command.verify_app) package_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( diff --git a/tests/platforms/android/gradle/test_package__debug_apk.py b/tests/platforms/android/gradle/test_package__debug_apk.py index 23d7b75a1..a611a2f22 100644 --- a/tests/platforms/android/gradle/test_package__debug_apk.py +++ b/tests/platforms/android/gradle/test_package__debug_apk.py @@ -23,7 +23,7 @@ def test_unsupported_template_version(package_command, first_app_generated): package_command.verify_app = MagicMock(wraps=package_command.verify_app) package_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py index 7a2a3ba5d..8490c3e9a 100644 --- a/tests/platforms/android/gradle/test_run.py +++ b/tests/platforms/android/gradle/test_run.py @@ -1,4 +1,5 @@ import datetime +import json import os import platform import sys @@ -9,6 +10,8 @@ import httpx import pytest +from briefcase.console import LogLevel +from briefcase.debuggers.base import BaseDebugger, DebuggerConnectionMode from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.android_sdk import ADB, AndroidSDK from briefcase.integrations.java import JDK @@ -89,6 +92,9 @@ def test_device_option(run_command): "update_stub": False, "no_update": False, "test_mode": False, + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "passthrough": [], "extra_emulator_args": None, "shutdown_on_exit": False, @@ -114,6 +120,9 @@ def test_extra_emulator_args_option(run_command): "update_stub": False, "no_update": False, "test_mode": False, + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "passthrough": [], "extra_emulator_args": ["-no-window", "-no-audio"], "shutdown_on_exit": False, @@ -137,6 +146,9 @@ def test_shutdown_on_exit_option(run_command): "update_stub": False, "no_update": False, "test_mode": False, + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "passthrough": [], "extra_emulator_args": None, "shutdown_on_exit": True, @@ -163,6 +175,9 @@ def test_forward_ports_option(run_command): "no_update": False, "test_mode": False, "passthrough": [], + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "extra_emulator_args": None, "shutdown_on_exit": False, "forward_ports": [80, 81], @@ -188,6 +203,9 @@ def test_reverse_ports_option(run_command): "no_update": False, "test_mode": False, "passthrough": [], + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "extra_emulator_args": None, "shutdown_on_exit": False, "forward_ports": None, @@ -209,7 +227,7 @@ def test_unsupported_template_version(run_command, first_app_generated): run_command.verify_app = mock.MagicMock(wraps=run_command.verify_app) run_command._briefcase_toml.update( - {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}} + {first_app_generated: {"briefcase": {"target_version": "0.3.16"}}} ) with pytest.raises( @@ -273,6 +291,7 @@ def mock_stream_output(app, stop_func, **kwargs): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) run_command.tools.mock_adb.forward_remove.assert_not_called() @@ -346,6 +365,7 @@ def mock_stream_output(app, stop_func, **kwargs): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", ["foo", "--bar"], + {}, ) run_command.tools.mock_adb.pidof.assert_called_once_with( @@ -395,6 +415,7 @@ def test_run_forward_reverse_ports(run_command, first_app_config): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) assert run_command.tools.mock_adb.forward_remove.mock_calls == [ @@ -542,6 +563,7 @@ def test_run_created_emulator(run_command, first_app_config): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) run_command.tools.mock_adb.logcat.assert_called_once_with(pid="777") @@ -603,6 +625,7 @@ def test_run_idle_device(run_command, first_app_config): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) run_command.tools.mock_adb.logcat.assert_called_once_with(pid="777") @@ -704,6 +727,7 @@ def mock_stream_output(app, stop_func, **kwargs): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) run_command.tools.mock_adb.pidof.assert_called_once_with( @@ -777,6 +801,7 @@ def mock_stream_output(app, stop_func, **kwargs): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", ["foo", "--bar"], + {}, ) run_command.tools.mock_adb.pidof.assert_called_once_with( @@ -854,6 +879,7 @@ def test_run_test_mode_created_emulator(run_command, first_app_config): f"{first_app_config.package_name}.{first_app_config.module_name}", "org.beeware.android.MainActivity", [], + {}, ) run_command.tools.mock_adb.logcat.assert_called_once_with(pid="777") @@ -869,3 +895,155 @@ def test_run_test_mode_created_emulator(run_command, first_app_config): # The emulator was killed at the end of the test run_command.tools.mock_adb.kill.assert_called_once_with() + + +class ServerDebugger(BaseDebugger): + @property + def name(self) -> str: + return "dummy" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + return DebuggerConnectionMode.SERVER + + @property + def debugger_support_pkg(self) -> str: + raise NotImplementedError + + +class ClientDebugger(BaseDebugger): + @property + def name(self) -> str: + return "dummy" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + return DebuggerConnectionMode.CLIENT + + @property + def debugger_support_pkg(self) -> str: + raise NotImplementedError + + +@pytest.mark.parametrize( + "debugger", + [ + ServerDebugger(), + ClientDebugger(), + ], +) +def test_run_debugger(run_command, first_app_config, tmp_path, debugger): + """An app can be run in debug mode.""" + run_command.console.verbosity = LogLevel.DEBUG + + # Set up device selection to return a running physical device. + run_command.tools.android_sdk.select_target_device = mock.MagicMock( + return_value=("exampleDevice", "ExampleDevice", None) + ) + + # Set up the log streamer to return a known stream + log_popen = mock.MagicMock() + run_command.tools.mock_adb.logcat.return_value = log_popen + + # To satisfy coverage, the stop function must be invoked at least once + # when invoking stream_output. + def mock_stream_output(app, stop_func, **kwargs): + stop_func() + + run_command._stream_app_logs.side_effect = mock_stream_output + + # Set up app config to have a `-` in the `bundle`, to ensure it gets + # normalized into a `_` via `package_name`. + first_app_config.bundle = "com.ex-ample" + + # Set up the debugger + first_app_config.debugger = debugger + first_app_config.debugger_host = "somehost" + first_app_config.debugger_port = 9999 + + # Invoke run_app with args. + run_command.run_app( + first_app_config, + device_or_avd="exampleDevice", + passthrough=[], + ) + + # select_target_device was invoked with a specific device + run_command.tools.android_sdk.select_target_device.assert_called_once_with( + "exampleDevice" + ) + + # The ADB wrapper is created + run_command.tools.android_sdk.adb.assert_called_once_with(device="exampleDevice") + + # The adb wrapper is invoked with the expected arguments + run_command.tools.mock_adb.install_apk.assert_called_once_with( + run_command.binary_path(first_app_config) + ) + run_command.tools.mock_adb.force_stop_app.assert_called_once_with( + f"{first_app_config.package_name}.{first_app_config.module_name}", + ) + + run_command.tools.mock_adb.start_app.assert_called_once_with( + f"{first_app_config.package_name}.{first_app_config.module_name}", + "org.beeware.android.MainActivity", + [], + { + "BRIEFCASE_DEBUG": "1", + "BRIEFCASE_DEBUGGER": json.dumps( + { + "debugger": "dummy", + "host": "somehost", + "port": 9999, + "host_os": platform.system(), + "app_path_mappings": { + "device_sys_path_regex": "app$", + "device_subfolders": ["first_app"], + "host_folders": [str(tmp_path / "base_path/src/first_app")], + }, + "app_packages_path_mappings": { + "sys_path_regex": "requirements$", + "host_folder": str( + tmp_path + / "base_path/build/first-app/android/gradle/app/build/python/pip/debug/common" + ), + }, + } + ), + }, + ) + + run_command.tools.mock_adb.pidof.assert_called_once_with( + f"{first_app_config.package_name}.{first_app_config.module_name}", + quiet=2, + ) + run_command.tools.mock_adb.logcat.assert_called_once_with(pid="777") + + if isinstance(debugger, ServerDebugger): + run_command.tools.mock_adb.forward.assert_called_once_with( + 9999, + 9999, + ) + run_command.tools.mock_adb.forward_remove.assert_called_once_with( + 9999, + ) + elif isinstance(debugger, ClientDebugger): + run_command.tools.mock_adb.reverse.assert_called_once_with( + 9999, + 9999, + ) + run_command.tools.mock_adb.reverse_remove.assert_called_once_with( + 9999, + ) + + run_command._stream_app_logs.assert_called_once_with( + first_app_config, + popen=log_popen, + clean_filter=android_log_clean_filter, + clean_output=False, + stop_func=mock.ANY, + log_stream=True, + ) + + # The emulator was not killed at the end of the test + run_command.tools.mock_adb.kill.assert_not_called()