diff --git a/changes/2369.feature.rst b/changes/2369.feature.rst new file mode 100644 index 000000000..43149ac6f --- /dev/null +++ b/changes/2369.feature.rst @@ -0,0 +1 @@ +Add ``--forward-port`` and ``--reverse-port`` via ADB when running an Android app diff --git a/docs/reference/platforms/android/gradle.rst b/docs/reference/platforms/android/gradle.rst index 43d63b84c..ca97030f9 100644 --- a/docs/reference/platforms/android/gradle.rst +++ b/docs/reference/platforms/android/gradle.rst @@ -159,7 +159,7 @@ You may specify multiple ``--Xemulator`` arguments; each one specifies a single argument to pass to the emulator, in the order they are specified. ``--shutdown-on-exit`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ Instruct Briefcase to shut down the emulator when the run finishes. This is especially useful if you are running in headless mode, as the emulator will @@ -167,6 +167,27 @@ continue to run in the background, but there will be no visual manifestation that it is running. It may also be useful as a cleanup mechanism when running in a CI configuration. +``--forward-port=`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Forward a port via ADB from the host to the Android device. This is useful when +a network service is running on the Android app that you want to connect to from +the host. + +You may specify multiple ``--forward-port`` arguments; each one specifies a +single port. + +``--reverse-port=`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Reverse a port via ADB from the Android device to the host. This is useful when +a network service is running on the host that you want to connect to from the +Android app. + +You may specify multiple ``--reverse-port`` arguments; each one specifies a +single port. + + Application configuration ========================= diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index e0ea3fe42..5d8942431 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -1628,6 +1628,86 @@ def logcat_tail(self, since: datetime): except subprocess.CalledProcessError as e: raise BriefcaseCommandError("Error starting ADB logcat.") from e + def forward(self, host_port: int, device_port: int): + """Use the forward command to set up arbitrary port forwarding, which + forwards requests on a specific host port to a different port on a device. + + :param host_port: The port on the host that should be forwarded to the device + :param device_port: The port on the device + """ + try: + self.tools.subprocess.check_output( + [ + self.tools.android_sdk.adb_path, + "-s", + self.device, + "forward", + f"tcp:{host_port}", + f"tcp:{device_port}", + ], + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError("Error starting 'adb forward'.") from e + + def forward_remove(self, host_port: int): + """Remove forwarded port. + + :param host_port: The port on the host that should be removed + """ + try: + self.tools.subprocess.check_output( + [ + self.tools.android_sdk.adb_path, + "-s", + self.device, + "forward", + "--remove", + f"tcp:{host_port}", + ], + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError("Error starting 'adb forward --remove'.") from e + + def reverse(self, device_port: int, host_port: int): + """Use the reverse command to set up arbitrary port forwarding, which + forwards requests on a specific device port to a different port on the host. + + :param device_port: The port on the device that should be forwarded to the host + :param host_port: The port on the host + """ + try: + self.tools.subprocess.check_output( + [ + self.tools.android_sdk.adb_path, + "-s", + self.device, + "reverse", + f"tcp:{device_port}", + f"tcp:{host_port}", + ], + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError("Error starting 'adb reverse'.") from e + + def reverse_remove(self, device_port: int): + """Remove reversed port. + + :param device_port: The port on the device that should be removed + """ + try: + self.tools.subprocess.check_output( + [ + self.tools.android_sdk.adb_path, + "-s", + self.device, + "reverse", + "--remove", + f"tcp:{device_port}", + ], + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError("Error starting 'adb reverse --remove'.") from e + def pidof(self, package: str, **kwargs) -> str | None: """Obtain the PID of a running app by package name. diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index d32f30a11..cc34b8fbd 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import datetime import re import subprocess @@ -18,7 +19,7 @@ from briefcase.config import AppConfig, parsed_version from briefcase.console import ANSI_ESC_SEQ_RE_DEF from briefcase.exceptions import BriefcaseCommandError -from briefcase.integrations.android_sdk import AndroidSDK +from briefcase.integrations.android_sdk import ADB, AndroidSDK from briefcase.integrations.subprocess import SubprocessArgT @@ -358,6 +359,20 @@ def add_options(self, parser): help="Shutdown the emulator on exit", required=False, ) + parser.add_argument( + "--forward-port", + action="append", + dest="forward_ports", + type=int, + help="Forward the specified port from host to device.", + ) + parser.add_argument( + "--reverse-port", + action="append", + dest="reverse_ports", + type=int, + help="Reverse the specified port from device to host.", + ) def run_app( self, @@ -366,6 +381,8 @@ def run_app( device_or_avd=None, extra_emulator_args=None, shutdown_on_exit=False, + forward_ports: list[int] | None = None, + reverse_ports: list[int] | None = None, **kwargs, ): """Start the application. @@ -376,6 +393,8 @@ def run_app( be asked to re-run the command selecting a specific device. :param extra_emulator_args: Any additional arguments to pass to the emulator. :param shutdown_on_exit: Should the emulator be shut down on exit? + :param forward_ports: A list of ports to forward for the app. + :param reverse_ports: A list of ports to reversed for the app. """ device, name, avd = self.tools.android_sdk.select_target_device(device_or_avd) @@ -424,55 +443,89 @@ def run_app( with self.console.wait_bar("Installing new app version..."): adb.install_apk(self.binary_path(app)) - # To start the app, we launch `org.beeware.android.MainActivity`. - with self.console.wait_bar(f"Launching {label}..."): - # capture the earliest time for device logging in case PID not found - device_start_time = adb.datetime() - - adb.start_app(package, "org.beeware.android.MainActivity", passthrough) - - # Try to get the PID for 5 seconds. - pid = None - fail_time = datetime.datetime.now() + datetime.timedelta(seconds=5) - while not pid and datetime.datetime.now() < fail_time: - # Try to get the PID; run in quiet mode because we may - # need to do this a lot in the next 5 seconds. - pid = adb.pidof(package, quiet=2) - if not pid: - time.sleep(0.01) - - if pid: - self.console.info( - "Following device log output (type CTRL-C to stop log)...", - prefix=app.app_name, - ) - # Start adb's logcat in a way that lets us stream the logs - log_popen = adb.logcat(pid=pid) - - # Stream the app logs. - self._stream_app_logs( - app, - popen=log_popen, - clean_filter=android_log_clean_filter, - clean_output=False, - # Check for the PID in quiet mode so logs aren't corrupted. - stop_func=lambda: not adb.pid_exists(pid=pid, quiet=2), - log_stream=True, - ) - else: - self.console.error("Unable to find PID for app", prefix=app.app_name) - self.console.error("Logs for launch attempt follow...") - self.console.error("=" * 75) - - # Show the log from the start time of the app - adb.logcat_tail(since=device_start_time) + forward_ports = forward_ports or [] + reverse_ports = reverse_ports or [] + + # Forward/Reverse requested ports + with self.forward_ports(adb, forward_ports, reverse_ports): + # To start the app, we launch `org.beeware.android.MainActivity`. + with self.console.wait_bar(f"Launching {label}..."): + # capture the earliest time for device logging in case PID not found + device_start_time = adb.datetime() + + adb.start_app( + package, "org.beeware.android.MainActivity", passthrough + ) + + # Try to get the PID for 5 seconds. + pid = None + fail_time = datetime.datetime.now() + datetime.timedelta(seconds=5) + while not pid and datetime.datetime.now() < fail_time: + # Try to get the PID; run in quiet mode because we may + # need to do this a lot in the next 5 seconds. + pid = adb.pidof(package, quiet=2) + if not pid: + time.sleep(0.01) + + if pid: + self.console.info( + "Following device log output (type CTRL-C to stop log)...", + prefix=app.app_name, + ) + # Start adb's logcat in a way that lets us stream the logs + log_popen = adb.logcat(pid=pid) + + # Stream the app logs. + self._stream_app_logs( + app, + popen=log_popen, + clean_filter=android_log_clean_filter, + clean_output=False, + # Check for the PID in quiet mode so logs aren't corrupted. + stop_func=lambda: not adb.pid_exists(pid=pid, quiet=2), + log_stream=True, + ) + else: + self.console.error( + "Unable to find PID for app", prefix=app.app_name + ) + self.console.error("Logs for launch attempt follow...") + self.console.error("=" * 75) + + # Show the log from the start time of the app + adb.logcat_tail(since=device_start_time) + + raise BriefcaseCommandError( + f"Problem starting app {app.app_name!r}" + ) - raise BriefcaseCommandError(f"Problem starting app {app.app_name!r}") finally: if shutdown_on_exit: with self.tools.console.wait_bar("Stopping emulator..."): adb.kill() + @contextlib.contextmanager + def forward_ports( + self, adb: ADB, forward_ports: list[int], reverse_ports: list[int] + ): + """Establish a port forwarding/reversion. + + :param adb: The ADB wrapper for the device + :param forward_ports: Ports to forward via ADB + :param reverse_ports: Ports to reverse via ADB + """ + for port in forward_ports: + adb.forward(port, port) + for port in reverse_ports: + adb.reverse(port, port) + + yield + + for port in forward_ports: + adb.forward_remove(port) + for port in reverse_ports: + adb.reverse_remove(port) + class GradlePackageCommand(GradleMixin, PackageCommand): description = "Create an Android App Bundle and APK in release mode." diff --git a/tests/integrations/android_sdk/ADB/test_forward_reverse.py b/tests/integrations/android_sdk/ADB/test_forward_reverse.py new file mode 100644 index 000000000..c2418a68b --- /dev/null +++ b/tests/integrations/android_sdk/ADB/test_forward_reverse.py @@ -0,0 +1,117 @@ +import subprocess + +import pytest + +from briefcase.exceptions import BriefcaseCommandError + + +def test_forward(mock_tools, adb): + """A port forwarding""" + # Invoke forward + adb.forward(5555, 6666) + + # Validate call parameters. + mock_tools.subprocess.check_output.assert_called_once_with( + [ + mock_tools.android_sdk.adb_path, + "-s", + "exampleDevice", + "forward", + "tcp:5555", + "tcp:6666", + ], + ) + + +def test_forward_failure(adb, mock_tools): + """If port forwarding fails, the error is caught.""" + # Mock out the run command on an adb instance + mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, cmd="" + ) + with pytest.raises(BriefcaseCommandError): + adb.forward(5555, 6666) + + +def test_forward_remove(mock_tools, adb): + """A port forwarding removal.""" + # Invoke forward remove + adb.forward_remove(5555) + + # Validate call parameters. + mock_tools.subprocess.check_output.assert_called_once_with( + [ + mock_tools.android_sdk.adb_path, + "-s", + "exampleDevice", + "forward", + "--remove", + "tcp:5555", + ], + ) + + +def test_forward_remove_failure(adb, mock_tools): + """If port forwarding removal fails, the error is caught.""" + # Mock out the run command on an adb instance + mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, cmd="" + ) + with pytest.raises(BriefcaseCommandError): + adb.forward_remove(5555) + + +def test_reverse(mock_tools, adb): + """A port reversing.""" + # Invoke reverse + adb.reverse(5555, 6666) + + # Validate call parameters. + mock_tools.subprocess.check_output.assert_called_once_with( + [ + mock_tools.android_sdk.adb_path, + "-s", + "exampleDevice", + "reverse", + "tcp:5555", + "tcp:6666", + ], + ) + + +def test_reverse_failure(adb, mock_tools): + """If port reversing fails, the error is caught.""" + # Mock out the run command on an adb instance + mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, cmd="" + ) + with pytest.raises(BriefcaseCommandError): + adb.reverse(5555, 6666) + + +def test_reverse_remove(mock_tools, adb): + """A port reversing removal.""" + # Invoke reverse remove + adb.reverse_remove(5555) + + # Validate call parameters. + mock_tools.subprocess.check_output.assert_called_once_with( + [ + mock_tools.android_sdk.adb_path, + "-s", + "exampleDevice", + "reverse", + "--remove", + "tcp:5555", + ], + ) + + +def test_reverse_remove_failure(adb, mock_tools): + """If port reversing removal fails, the error is caught.""" + # Mock out the run command on an adb instance + mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, cmd="" + ) + with pytest.raises(BriefcaseCommandError): + adb.reverse_remove(5555) diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py index 2f6fb6485..c6aacea55 100644 --- a/tests/platforms/android/gradle/test_run.py +++ b/tests/platforms/android/gradle/test_run.py @@ -93,6 +93,8 @@ def test_device_option(run_command): "passthrough": [], "extra_emulator_args": None, "shutdown_on_exit": False, + "forward_ports": None, + "reverse_ports": None, } assert overrides == {} @@ -116,6 +118,8 @@ def test_extra_emulator_args_option(run_command): "passthrough": [], "extra_emulator_args": ["-no-window", "-no-audio"], "shutdown_on_exit": False, + "forward_ports": None, + "reverse_ports": None, } assert overrides == {} @@ -137,6 +141,58 @@ def test_shutdown_on_exit_option(run_command): "passthrough": [], "extra_emulator_args": None, "shutdown_on_exit": True, + "forward_ports": None, + "reverse_ports": None, + } + assert overrides == {} + + +def test_forward_ports_option(run_command): + """The --forward-port option can be parsed.""" + options, overrides = run_command.parse_options( + ["--forward-port", "80", "--forward-port", "81"] + ) + + assert options == { + "device_or_avd": None, + "appname": None, + "update": False, + "update_requirements": False, + "update_resources": False, + "update_support": False, + "update_stub": False, + "no_update": False, + "test_mode": False, + "passthrough": [], + "extra_emulator_args": None, + "shutdown_on_exit": False, + "forward_ports": [80, 81], + "reverse_ports": None, + } + assert overrides == {} + + +def test_reverse_ports_option(run_command): + """The --reverse-port option can be parsed.""" + options, overrides = run_command.parse_options( + ["--reverse-port", "78", "--reverse-port", "79"] + ) + + assert options == { + "device_or_avd": None, + "appname": None, + "update": False, + "update_requirements": False, + "update_resources": False, + "update_support": False, + "update_stub": False, + "no_update": False, + "test_mode": False, + "passthrough": [], + "extra_emulator_args": None, + "shutdown_on_exit": False, + "forward_ports": None, + "reverse_ports": [78, 79], } assert overrides == {} @@ -211,12 +267,18 @@ def mock_stream_output(app, stop_func, **kwargs): f"{first_app_config.package_name}.{first_app_config.module_name}", ) + run_command.tools.mock_adb.forward.assert_not_called() + run_command.tools.mock_adb.reverse.assert_not_called() + 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", [], ) + run_command.tools.mock_adb.forward_remove.assert_not_called() + run_command.tools.mock_adb.reverse_remove.assert_not_called() + run_command.tools.mock_adb.pidof.assert_called_once_with( f"{first_app_config.package_name}.{first_app_config.module_name}", quiet=2, @@ -306,6 +368,46 @@ def mock_stream_output(app, stop_func, **kwargs): run_command.tools.mock_adb.kill.assert_not_called() +def test_run_forward_reverse_ports(run_command, first_app_config): + """An app can be run with port forwarding and reversing.""" + # 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) + ) + + # Invoke run_app with args. + run_command.run_app( + first_app_config, + passthrough=[], + forward_ports=[80, 81], + reverse_ports=[78, 79], + ) + + assert run_command.tools.mock_adb.forward.mock_calls == [ + mock.call(80, 80), + mock.call(81, 81), + ] + assert run_command.tools.mock_adb.reverse.mock_calls == [ + mock.call(78, 78), + mock.call(79, 79), + ] + + 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", + [], + ) + + assert run_command.tools.mock_adb.forward_remove.mock_calls == [ + mock.call(80), + mock.call(81), + ] + assert run_command.tools.mock_adb.reverse_remove.mock_calls == [ + mock.call(78), + mock.call(79), + ] + + def test_run_slow_start(run_command, first_app_config, monkeypatch): """If the app is slow to start, multiple calls to pidof will be made.""" run_command.tools.android_sdk.select_target_device = mock.MagicMock(