From 8ee047bb825c779b57d93070da22cf323a88654f Mon Sep 17 00:00:00 2001 From: Kirk Date: Wed, 30 Apr 2025 20:43:57 -0400 Subject: [PATCH 01/16] Add Android driver boilerplate and ADB server --- adb-exporter.yaml | 10 +++ packages/jumpstarter-all/pyproject.toml | 1 + packages/jumpstarter-driver-android/README.md | 25 ++++++ .../jumpstarter_driver_android/__init__.py | 0 .../jumpstarter_driver_android/client.py | 9 +++ .../jumpstarter_driver_android/driver.py | 78 +++++++++++++++++++ .../jumpstarter_driver_android/py.typed | 0 .../jumpstarter-driver-android/pyproject.toml | 27 +++++++ pyproject.toml | 1 + uv.lock | 31 ++++++++ 10 files changed, 182 insertions(+) create mode 100644 adb-exporter.yaml create mode 100644 packages/jumpstarter-driver-android/README.md create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/__init__.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/py.typed create mode 100644 packages/jumpstarter-driver-android/pyproject.toml diff --git a/adb-exporter.yaml b/adb-exporter.yaml new file mode 100644 index 000000000..32048d833 --- /dev/null +++ b/adb-exporter.yaml @@ -0,0 +1,10 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + name: local-exporter + namespace: default +endpoint: "" +token: "" +export: + adb: + type: jumpstarter_driver_android.driver.AdbServer diff --git a/packages/jumpstarter-all/pyproject.toml b/packages/jumpstarter-all/pyproject.toml index 55191d974..50bc9b761 100644 --- a/packages/jumpstarter-all/pyproject.toml +++ b/packages/jumpstarter-all/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "jumpstarter-cli-admin", "jumpstarter-cli-common", "jumpstarter-cli-driver", + "jumpstarter-driver-android", "jumpstarter-driver-can", "jumpstarter-driver-composite", "jumpstarter-driver-corellium", diff --git a/packages/jumpstarter-driver-android/README.md b/packages/jumpstarter-driver-android/README.md new file mode 100644 index 000000000..0a7d400e4 --- /dev/null +++ b/packages/jumpstarter-driver-android/README.md @@ -0,0 +1,25 @@ +# Android Driver + +`jumpstarter-driver-android` provides ADB and Android emulator functionality for Jumpstarter. + +## Installation + +```bash +pip install jumpstarter-driver-android +``` + +## Configuration + +Example configuration: + +```yaml +export: + composite: + type: jumpstarter_driver_android.driver.Adb + config: + # Add required config parameters here +``` + +## API Reference + +Add API documentation here. diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/__init__.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py new file mode 100644 index 000000000..d1c6ec487 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -0,0 +1,9 @@ +from jumpstarter.client import DriverClient + + +class AdbClient(DriverClient): + """Power client for controlling power devices.""" + + def connect(self) -> None: + """Connect to the ADB server.""" + pass diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py new file mode 100644 index 000000000..b0b813580 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py @@ -0,0 +1,78 @@ +import os +import shutil +import subprocess +from dataclasses import field +from typing import Optional + +from pydantic.dataclasses import dataclass + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver import Driver + + +@dataclass(kw_only=True) +class AdbServer(Driver): + port: int = 5037 + adb_executable: Optional[str] = None + + _adb_path: Optional[str] = field(init=False, default=None) + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_android.client.AdbClient" + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + if self.port < 0 or self.port > 65535: + raise ConfigurationError(f"Invalid port number: {self.port}") + if not isinstance(self.port, int): + raise ConfigurationError(f"Port must be an integer: {self.port}") + + self.logger.info(f"ADB server will run on port {self.port}") + + if not self.adb_executable: + self._adb_path = shutil.which("adb") + if not self._adb_path: + raise ConfigurationError(f"ADB executable '{self.adb_executable}' not found in PATH.") + else: + self._adb_path = self.adb_executable + self.logger.info(f"ADB Executable: {self._adb_path}") + + try: + result = subprocess.run( + [self._adb_path, "version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + self.logger.info(f"ADB Version Info: {result.stdout.strip()}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to execute adb: {e}") + + try: + result = subprocess.run( + [self._adb_path, "start-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"ANDROID_ADB_SERVER_PORT": str(self.port), **dict(os.environ)}, + ) + self.logger.info(f"ADB server started on port {self.port}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to start ADB server: {e}") + + def close(self): + try: + subprocess.run( + [self._adb_path, "kill-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.logger.info("ADB server stopped") + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to stop ADB server: {e}") + except Exception as e: + self.logger.error(f"Unexpected error while stopping ADB server: {e}") + super().close() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/py.typed b/packages/jumpstarter-driver-android/jumpstarter_driver_android/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-android/pyproject.toml b/packages/jumpstarter-driver-android/pyproject.toml new file mode 100644 index 000000000..cc17d7c0a --- /dev/null +++ b/packages/jumpstarter-driver-android/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "jumpstarter-driver-android" +dynamic = ["version", "urls"] +description = "" +authors = [{ name = "Kirk Brauer", email = "kbrauer@hatci.com" }] +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +dependencies = ["jumpstarter", "asyncclick>=8.1.7.2"] + +[project.entry-points."jumpstarter.drivers"] +MockPower = "jumpstarter_driver_android.driver:AdbServer" + +[dependency-groups] +dev = ["pytest>=8.3.2", "pytest-cov>=5.0.0", "trio>=0.28.0"] + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../' } + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" diff --git a/pyproject.toml b/pyproject.toml index 41a51cd61..4b8df3c99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ jumpstarter-cli = { workspace = true } jumpstarter-cli-admin = { workspace = true } jumpstarter-cli-common = { workspace = true } jumpstarter-cli-driver = { workspace = true } +jumpstarter-driver-android = { workspace = true } jumpstarter-driver-can = { workspace = true } jumpstarter-driver-composite = { workspace = true } jumpstarter-driver-corellium = { workspace = true } diff --git a/uv.lock b/uv.lock index 120052fee..d9bb6a2e4 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ members = [ "jumpstarter-cli-admin", "jumpstarter-cli-common", "jumpstarter-cli-driver", + "jumpstarter-driver-android", "jumpstarter-driver-can", "jumpstarter-driver-composite", "jumpstarter-driver-corellium", @@ -975,6 +976,7 @@ dependencies = [ { name = "jumpstarter-cli-admin" }, { name = "jumpstarter-cli-common" }, { name = "jumpstarter-cli-driver" }, + { name = "jumpstarter-driver-android" }, { name = "jumpstarter-driver-can" }, { name = "jumpstarter-driver-composite" }, { name = "jumpstarter-driver-corellium" }, @@ -1008,6 +1010,7 @@ requires-dist = [ { name = "jumpstarter-cli-admin", editable = "packages/jumpstarter-cli-admin" }, { name = "jumpstarter-cli-common", editable = "packages/jumpstarter-cli-common" }, { name = "jumpstarter-cli-driver", editable = "packages/jumpstarter-cli-driver" }, + { name = "jumpstarter-driver-android", editable = "packages/jumpstarter-driver-android" }, { name = "jumpstarter-driver-can", editable = "packages/jumpstarter-driver-can" }, { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, { name = "jumpstarter-driver-corellium", editable = "packages/jumpstarter-driver-corellium" }, @@ -1166,6 +1169,34 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-android" +source = { editable = "packages/jumpstarter-driver-android" } +dependencies = [ + { name = "asyncclick" }, + { name = "jumpstarter" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "trio" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncclick", specifier = ">=8.1.7.2" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.2" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "trio", specifier = ">=0.28.0" }, +] + [[package]] name = "jumpstarter-driver-can" source = { editable = "packages/jumpstarter-driver-can" } From 3aacde0b5ebef6c98f95ce91a3a3e04f86b91c55 Mon Sep 17 00:00:00 2001 From: Kirk Date: Wed, 30 Apr 2025 23:07:06 -0400 Subject: [PATCH 02/16] Add ADB forwarding to client --- adb-exporter.yaml | 2 + .../jumpstarter_driver_android/client.py | 35 +++++++++++- .../jumpstarter_driver_android/driver.py | 8 ++- .../jumpstarter-driver-android/pyproject.toml | 7 ++- uv.lock | 56 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/adb-exporter.yaml b/adb-exporter.yaml index 32048d833..40510bd57 100644 --- a/adb-exporter.yaml +++ b/adb-exporter.yaml @@ -8,3 +8,5 @@ token: "" export: adb: type: jumpstarter_driver_android.driver.AdbServer + config: + port: 3000 diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index d1c6ec487..3d981948f 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -1,9 +1,38 @@ +from ipaddress import IPv6Address, ip_address +from threading import Event + +import asyncclick as click +from jumpstarter_driver_network.adapters import TcpPortforwardAdapter + from jumpstarter.client import DriverClient class AdbClient(DriverClient): """Power client for controlling power devices.""" - def connect(self) -> None: - """Connect to the ADB server.""" - pass + def cli(self): + @click.group + def base(): + """ADB Client""" + pass + + @base.command() + @click.option("--address", default="localhost", show_default=True) + @click.option("--port", type=int, default=5037, show_default=True) + def start(address: str, port: int): + with TcpPortforwardAdapter( + client=self, + local_host=address, + local_port=port, + ) as addr: + host = ip_address(addr[0]) + port = addr[1] + match host: + case IPv6Address(): + click.echo("[{}]:{}".format(host, port)) + case _: + click.echo("{}:{}".format(host, port)) + + Event().wait() + + return base diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py index b0b813580..18878c6c8 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py @@ -2,22 +2,24 @@ import shutil import subprocess from dataclasses import field -from typing import Optional +from typing import Optional, override +from jumpstarter_driver_network.driver import TcpNetwork from pydantic.dataclasses import dataclass from jumpstarter.common.exceptions import ConfigurationError -from jumpstarter.driver import Driver @dataclass(kw_only=True) -class AdbServer(Driver): +class AdbServer(TcpNetwork): + host: str = "127.0.0.1" port: int = 5037 adb_executable: Optional[str] = None _adb_path: Optional[str] = field(init=False, default=None) @classmethod + @override def client(cls) -> str: return "jumpstarter_driver_android.client.AdbClient" diff --git a/packages/jumpstarter-driver-android/pyproject.toml b/packages/jumpstarter-driver-android/pyproject.toml index cc17d7c0a..165991ec7 100644 --- a/packages/jumpstarter-driver-android/pyproject.toml +++ b/packages/jumpstarter-driver-android/pyproject.toml @@ -6,7 +6,12 @@ authors = [{ name = "Kirk Brauer", email = "kbrauer@hatci.com" }] readme = "README.md" license = "Apache-2.0" requires-python = ">=3.11" -dependencies = ["jumpstarter", "asyncclick>=8.1.7.2"] +dependencies = [ + "jumpstarter", + "jumpstarter-driver-network", + "asyncclick>=8.1.7.2", + "adbutils>=2.8.7", +] [project.entry-points."jumpstarter.drivers"] MockPower = "jumpstarter_driver_android.driver:AdbServer" diff --git a/uv.lock b/uv.lock index d9bb6a2e4..e7a947cea 100644 --- a/uv.lock +++ b/uv.lock @@ -61,6 +61,24 @@ docs = [ { name = "sphinxcontrib-programoutput", specifier = ">=0.18" }, ] +[[package]] +name = "adbutils" +version = "2.8.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "pillow" }, + { name = "requests" }, + { name = "retry" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b8/d90093a3bc54c9d61195500213d929c92bde085579cf54f2eb97a25400c0/adbutils-2.8.7.tar.gz", hash = "sha256:8e3489d4a8369500951f08cfe6dbfde1ddbdf86b9aaa96641f822181195fa0cf", size = 185622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/02/bc01843446aef003e01642292bc31f728b7528cb999488a107d609c3ab9b/adbutils-2.8.7-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:443ffbb0f72532d6cfa88f5df1735b401bcd73265fab6430b3d8fc434f445d30", size = 6705052 }, + { url = "https://files.pythonhosted.org/packages/53/c8/7d65da1a2de875ee6aa44e3e743e3d586c0bd4fb415a2ae7a5a9ce5d524d/adbutils-2.8.7-py3-none-manylinux1_x86_64.whl", hash = "sha256:b44d521f9ef8453738f1d8abac84e86242715a7a6446f23e36536a6efea37c41", size = 3576001 }, + { url = "https://files.pythonhosted.org/packages/b1/98/75b6f6f85a81ed9e0de79391642ddf367f6442d764fe7b24dd7d8fb8c891/adbutils-2.8.7-py3-none-win32.whl", hash = "sha256:258686a43d5fc7820ffa72416bd57883b5fdf304f231718ed8f134105475b89f", size = 3336619 }, + { url = "https://files.pythonhosted.org/packages/ae/e9/c181fc4bfd220a89bc0fe0d1f32e86c1797710d2a76c64bb2522cfe76b68/adbutils-2.8.7-py3-none-win_amd64.whl", hash = "sha256:e74dbf9dc6b83e75dec9f6758a880557c263fc7ee4bff7d09ee736aab7b7b762", size = 3336623 }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -588,6 +606,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -1173,8 +1203,10 @@ dev = [ name = "jumpstarter-driver-android" source = { editable = "packages/jumpstarter-driver-android" } dependencies = [ + { name = "adbutils" }, { name = "asyncclick" }, { name = "jumpstarter" }, + { name = "jumpstarter-driver-network" }, ] [package.dev-dependencies] @@ -1186,8 +1218,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "adbutils", specifier = ">=2.8.7" }, { name = "asyncclick", specifier = ">=8.1.7.2" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-network", editable = "packages/jumpstarter-driver-network" }, ] [package.metadata.requires-dev] @@ -2645,6 +2679,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, ] +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708 }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -3075,6 +3118,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, ] +[[package]] +name = "retry" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/72/75d0b85443fbc8d9f38d08d2b1b67cc184ce35280e4a3813cda2f445f3a4/retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4", size = 6448 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/0d/53aea75710af4528a25ed6837d71d117602b01946b307a3912cb3cfcbcba/retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606", size = 7986 }, +] + [[package]] name = "rich" version = "14.0.0" From 96f107352920bc22256aa0ca0560e60954d8657c Mon Sep 17 00:00:00 2001 From: Kirk Date: Sun, 4 May 2025 15:18:49 -0400 Subject: [PATCH 03/16] Add full support for adb passthrough mode --- .../jumpstarter_driver_android/client.py | 145 ++++++++++++++---- 1 file changed, 118 insertions(+), 27 deletions(-) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index 3d981948f..b63ba13e9 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -1,7 +1,12 @@ -from ipaddress import IPv6Address, ip_address -from threading import Event +import os +import subprocess +import sys +from contextlib import contextmanager +from typing import Generator +import adbutils import asyncclick as click +from anyio import Event from jumpstarter_driver_network.adapters import TcpPortforwardAdapter from jumpstarter.client import DriverClient @@ -10,29 +15,115 @@ class AdbClient(DriverClient): """Power client for controlling power devices.""" + @contextmanager + def forward_adb(self, host: str, port: int) -> Generator[str, None, None]: + """ + Port-forward remote ADB server to local host and port. + + Args: + host (str): The local host to forward to. + port (int): The local port to forward to. + + Yields: + str: The address of the forwarded ADB server. + """ + with TcpPortforwardAdapter( + client=self, + local_host=host, + local_port=port, + ) as addr: + yield addr + + @contextmanager + def adb_client(self, host: str = "127.0.0.1", port: int = 5037) -> Generator[adbutils.AdbClient, None, None]: + """ + Context manager to get an `adbutils.AdbClient`. + + Args: + host (str): The local host to forward to. + port (int): The local port to forward to. + + Yields: + adbutils.AdbClient: The `adbutils.AdbClient` instance. + """ + with self.forward_adb(host, port) as addr: + client = adbutils.AdbClient(host=addr[0], port=int(addr[1])) + yield client + Event.wait() + def cli(self): - @click.group - def base(): - """ADB Client""" - pass - - @base.command() - @click.option("--address", default="localhost", show_default=True) - @click.option("--port", type=int, default=5037, show_default=True) - def start(address: str, port: int): - with TcpPortforwardAdapter( - client=self, - local_host=address, - local_port=port, - ) as addr: - host = ip_address(addr[0]) - port = addr[1] - match host: - case IPv6Address(): - click.echo("[{}]:{}".format(host, port)) - case _: - click.echo("{}:{}".format(host, port)) - - Event().wait() - - return base + @click.command(context_settings={"ignore_unknown_options": True}) + @click.option("host", "-H", default="127.0.0.1", show_default=True, help="Local adb host to forward to.") + @click.option("port", "-P", type=int, default=5037, show_default=True, help="Local adb port to forward to.") + @click.option("-a", is_flag=True, hidden=True) + @click.option("-d", is_flag=True, hidden=True) + @click.option("-e", is_flag=True, hidden=True) + @click.option("-L", hidden=True) + @click.option("--one-device", hidden=True) + @click.option( + "--adb", + default="adb", + show_default=True, + help="Path to the ADB executable", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + ) + @click.argument("args", nargs=-1) + def adb( + host: str, + port: int, + adb: str, + a: bool, + d: bool, + e: bool, + l: str, # noqa: E741 + one_device: str, + args: tuple[str, ...], + ): + """ + Run commands using a local adb executable against the remote adb server. This command is a wrapper around + the adb command-line tool. It allows you to run adb commands against a remote ADB server tunneled through + Jumpstarter. + + When executing this command, the adb server address and port are forwarded to the local ADB executable. The + adb server address and port are set in the environment variables ANDROID_ADB_SERVER_ADDRESS and + ANDROID_ADB_SERVER_PORT, respectively. This allows the local ADB executable to communicate with the remote + adb server. + + Most command line arguments and commands are passed directly to the adb executable. However, some + arguments and commands are not supported by the Jumpstarter adb client. These options include: + -a, -d, -e, -L, --one-device. + + The following adb commands are also not supported: start-server, kill-server, connect, disconnect, + reconnect, nodaemon, pair + """ + # Throw exception for all unsupported arguments + if any([a, d, e, l, one_device]): + raise click.UsageError( + "ADB options -a, -d, -e, -L, and --one-device are not supported by the Jumpstarter ADB client" + ) + # Check for unsupported server management commands + unsupported_commands = [ + "start-server", + "kill-server", + "connect", + "disconnect", + "reconnect", + "nodaemon", + "pair", + ] + for arg in args: + if arg in unsupported_commands: + raise click.UsageError(f"ADB command '{arg}' is not supported by the Jumpstarter ADB client") + + # Forward the ADB server address and port and call ADB executable with args + with self.forward_adb(host, port) as addr: + env = os.environ | { + "ANDROID_ADB_SERVER_ADDRESS": addr[0], + "ANDROID_ADB_SERVER_PORT": str(addr[1]), + } + cmd = [adb, *args] + print(cmd) + process = subprocess.Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) + return process.wait() + + return adb From 765119e27f3a64c6e7a6b1421d8a4ede8d8c7bd9 Mon Sep 17 00:00:00 2001 From: Kirk Date: Sun, 4 May 2025 22:33:56 -0400 Subject: [PATCH 04/16] Add full emulator and ADB support --- .../jumpstarter_driver_android/client.py | 28 +- .../jumpstarter_driver_android/driver.py | 80 ----- .../driver/__init__.py | 11 + .../jumpstarter_driver_android/driver/adb.py | 109 +++++++ .../driver/emulator.py | 296 ++++++++++++++++++ .../driver/options.py | 187 +++++++++++ .../jumpstarter-driver-android/pyproject.toml | 2 + .../jumpstarter_driver_network/driver.py | 9 +- .../jumpstarter/jumpstarter/common/utils.py | 16 +- 9 files changed, 639 insertions(+), 99 deletions(-) create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index b63ba13e9..c1ffc9b36 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -6,7 +6,7 @@ import adbutils import asyncclick as click -from anyio import Event +from jumpstarter_driver_composite.client import CompositeClient from jumpstarter_driver_network.adapters import TcpPortforwardAdapter from jumpstarter.client import DriverClient @@ -35,7 +35,7 @@ def forward_adb(self, host: str, port: int) -> Generator[str, None, None]: yield addr @contextmanager - def adb_client(self, host: str = "127.0.0.1", port: int = 5037) -> Generator[adbutils.AdbClient, None, None]: + def adb_client(self, host: str = "127.0.0.1", port: int = 5038) -> Generator[adbutils.AdbClient, None, None]: """ Context manager to get an `adbutils.AdbClient`. @@ -49,12 +49,11 @@ def adb_client(self, host: str = "127.0.0.1", port: int = 5037) -> Generator[adb with self.forward_adb(host, port) as addr: client = adbutils.AdbClient(host=addr[0], port=int(addr[1])) yield client - Event.wait() def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @click.option("host", "-H", default="127.0.0.1", show_default=True, help="Local adb host to forward to.") - @click.option("port", "-P", type=int, default=5037, show_default=True, help="Local adb port to forward to.") + @click.option("port", "-P", type=int, default=5038, show_default=True, help="Local adb port to forward to.") @click.option("-a", is_flag=True, hidden=True) @click.option("-d", is_flag=True, hidden=True) @click.option("-e", is_flag=True, hidden=True) @@ -65,7 +64,6 @@ def cli(self): default="adb", show_default=True, help="Path to the ADB executable", - type=click.Path(exists=True, dir_okay=False, resolve_path=True), ) @click.argument("args", nargs=-1) def adb( @@ -93,7 +91,7 @@ def adb( arguments and commands are not supported by the Jumpstarter adb client. These options include: -a, -d, -e, -L, --one-device. - The following adb commands are also not supported: start-server, kill-server, connect, disconnect, + The following adb commands are also not supported: connect, disconnect, reconnect, nodaemon, pair """ # Throw exception for all unsupported arguments @@ -103,8 +101,6 @@ def adb( ) # Check for unsupported server management commands unsupported_commands = [ - "start-server", - "kill-server", "connect", "disconnect", "reconnect", @@ -115,6 +111,15 @@ def adb( if arg in unsupported_commands: raise click.UsageError(f"ADB command '{arg}' is not supported by the Jumpstarter ADB client") + if "start-server" in args: + remote_port = int(self.call("start_server")) + click.echo(f"ADB server started on remote port exporter:{remote_port}") + return 0 + if "kill-server" in args: + remote_port = int(self.call("kill_server")) + click.echo(f"ADB server killed on remote port exporter:{remote_port}") + return 0 + # Forward the ADB server address and port and call ADB executable with args with self.forward_adb(host, port) as addr: env = os.environ | { @@ -122,8 +127,13 @@ def adb( "ANDROID_ADB_SERVER_PORT": str(addr[1]), } cmd = [adb, *args] - print(cmd) process = subprocess.Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) return process.wait() return adb + + +class AndroidClient(CompositeClient): + """Generic Android client for controlling Android devices/emulators.""" + + pass diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py index 18878c6c8..e69de29bb 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py @@ -1,80 +0,0 @@ -import os -import shutil -import subprocess -from dataclasses import field -from typing import Optional, override - -from jumpstarter_driver_network.driver import TcpNetwork -from pydantic.dataclasses import dataclass - -from jumpstarter.common.exceptions import ConfigurationError - - -@dataclass(kw_only=True) -class AdbServer(TcpNetwork): - host: str = "127.0.0.1" - port: int = 5037 - adb_executable: Optional[str] = None - - _adb_path: Optional[str] = field(init=False, default=None) - - @classmethod - @override - def client(cls) -> str: - return "jumpstarter_driver_android.client.AdbClient" - - def __post_init__(self): - if hasattr(super(), "__post_init__"): - super().__post_init__() - - if self.port < 0 or self.port > 65535: - raise ConfigurationError(f"Invalid port number: {self.port}") - if not isinstance(self.port, int): - raise ConfigurationError(f"Port must be an integer: {self.port}") - - self.logger.info(f"ADB server will run on port {self.port}") - - if not self.adb_executable: - self._adb_path = shutil.which("adb") - if not self._adb_path: - raise ConfigurationError(f"ADB executable '{self.adb_executable}' not found in PATH.") - else: - self._adb_path = self.adb_executable - self.logger.info(f"ADB Executable: {self._adb_path}") - - try: - result = subprocess.run( - [self._adb_path, "version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - self.logger.info(f"ADB Version Info: {result.stdout.strip()}") - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to execute adb: {e}") - - try: - result = subprocess.run( - [self._adb_path, "start-server"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env={"ANDROID_ADB_SERVER_PORT": str(self.port), **dict(os.environ)}, - ) - self.logger.info(f"ADB server started on port {self.port}") - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to start ADB server: {e}") - - def close(self): - try: - subprocess.run( - [self._adb_path, "kill-server"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - self.logger.info("ADB server stopped") - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to stop ADB server: {e}") - except Exception as e: - self.logger.error(f"Unexpected error while stopping ADB server: {e}") - super().close() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py new file mode 100644 index 000000000..d49bebeb5 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py @@ -0,0 +1,11 @@ +from .adb import AdbServer +from .emulator import AndroidEmulator, AndroidEmulatorPower +from .options import AdbOptions, EmulatorOptions + +__all__ = [ + "AdbServer", + "AndroidEmulator", + "AndroidEmulatorPower", + "AdbOptions", + "EmulatorOptions", +] diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py new file mode 100644 index 000000000..1d44cea00 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py @@ -0,0 +1,109 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass +from typing import override + +from jumpstarter_driver_network.driver import TcpNetwork + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver.decorators import export + + +@dataclass(kw_only=True) +class AdbServer(TcpNetwork): + adb_path: str = "adb" + host: str = "127.0.0.1" + port: int = 5037 + + @classmethod + @override + def client(cls) -> str: + return "jumpstarter_driver_android.client.AdbClient" + + def _print_output(self, output: str, error=False, debug=False): + if output: + for line in output.strip().split("\n"): + if error: + self.logger.error(line) + elif debug: + self.logger.debug(line) + else: + self.logger.info(line) + + @export + def start_server(self): + """ + Start the ADB server. + """ + self.logger.debug(f"Starting ADB server on port {self.port}") + try: + result = subprocess.run( + [self.adb_path, "start-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"ANDROID_ADB_SERVER_PORT": str(self.port), **dict(os.environ)}, + ) + self._print_output(result.stdout) + self._print_output(result.stderr, debug=True) + self.logger.info(f"ADB server started on port {self.port}") + except subprocess.CalledProcessError as e: + self._print_output(e.stdout) + self._print_output(e.stderr, debug=True) + self.logger.error(f"Failed to start ADB server: {e}") + return self.port + + @export + def kill_server(self): + """ + Kill the ADB server. + """ + self.logger.debug(f"Killing ADB server on port {self.port}") + try: + result = subprocess.run( + [self.adb_path, "kill-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"ANDROID_ADB_SERVER_PORT": str(self.port), **dict(os.environ)}, + ) + self._print_output(result.stdout) + self._print_output(result.stderr, error=True) + self.logger.info(f"ADB server stopped on port {self.port}") + except subprocess.CalledProcessError as e: + self._print_output(e.stdout) + self._print_output(e.stderr, error=True) + self.logger.error(f"Failed to stop ADB server: {e}") + except Exception as e: + self.logger.error(f"Unexpected error while stopping ADB server: {e}") + return self.port + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + if self.port < 0 or self.port > 65535: + raise ConfigurationError(f"Invalid port number: {self.port}") + if not isinstance(self.port, int): + raise ConfigurationError(f"Port must be an integer: {self.port}") + + self.logger.info(f"ADB server will run on port {self.port}") + + if self.adb_path == "adb": + self.adb_path = shutil.which("adb") + if not self.adb_path: + raise ConfigurationError(f"ADB executable '{self.adb_executable}' not found in PATH.") + + try: + result = subprocess.run( + [self.adb_path, "version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + self._print_output(result.stdout, debug=True) + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to execute adb: {e}") + + def close(self): + self.kill_server() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py new file mode 100644 index 000000000..2bba0c844 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -0,0 +1,296 @@ +import subprocess +import threading +from dataclasses import dataclass, field +from subprocess import TimeoutExpired +from typing import IO, AsyncGenerator, Optional, override + +from anyio.abc import Process +from jumpstarter_driver_power.common import PowerReading +from jumpstarter_driver_power.driver import PowerInterface + +from jumpstarter_driver_android.driver.adb import AdbServer +from jumpstarter_driver_android.driver.options import AdbOptions, EmulatorOptions + +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class AndroidEmulator(Driver): + """ + AndroidEmulator class provides an interface to configure and manage an Android Emulator instance. + """ + + adb: AdbOptions + emulator: EmulatorOptions + + @classmethod + @override + def client(cls) -> str: + return "jumpstarter_driver_android.client.AndroidClient" + + def __init__(self, **kwargs): + self.adb = AdbOptions.model_validate(kwargs.get("adb", {})) + self.emulator = EmulatorOptions.model_validate(kwargs.get("emulator", {})) + if hasattr(super(), "__init__"): + super().__init__() + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + self.children["adb"] = AdbServer(host=self.adb.host, port=self.adb.port, adb_path=self.adb.adb_path) + self.children["power"] = AndroidEmulatorPower(parent=self) + + +@dataclass(kw_only=True) +class AndroidEmulatorPower(PowerInterface, Driver): + parent: AndroidEmulator + + _process: Optional[Process] = field(init=False, repr=False, compare=False, default=None) + _log_thread: Optional[threading.Thread] = field(init=False, repr=False, compare=False, default=None) + _stderr_thread: Optional[threading.Thread] = field(init=False, repr=False, compare=False, default=None) + + def _process_logs(self, pipe: IO[bytes], is_stderr: bool = False) -> None: + """Process logs from the emulator and redirect them to the Python logger.""" + try: + for line in iter(pipe.readline, b""): + line_str = line.decode("utf-8", errors="replace").strip() + if not line_str: + continue + + # Extract log level if present + if "|" in line_str: + level_str, message = line_str.split("|", 1) + level_str = level_str.strip().upper() + message = message.strip() + + # Map emulator log levels to Python logging levels + if "ERROR" in level_str or "FATAL" in level_str: + self.logger.error(message) + elif "WARN" in level_str: + self.logger.warning(message) + elif "DEBUG" in level_str: + self.logger.debug(message) + elif "INFO" in level_str: + self.logger.info(message) + else: + # Default to info for unknown levels + self.logger.info(line_str) + else: + # If no level specified, use INFO for stdout and ERROR for stderr + if is_stderr: + self.logger.error(line_str) + else: + self.logger.info(line_str) + except (ValueError, IOError) as e: + self.logger.error(f"Error processing emulator logs: {e}") + finally: + pipe.close() + + def _make_emulator_command(self) -> list[str]: + """Construct the command to start the Android emulator.""" + cmdline = [ + self.parent.emulator.emulator_path, + "-avd", + self.parent.emulator.avd, + ] + + # Add emulator arguments from EmulatorArguments + args = self.parent.emulator + + # System/Core Arguments + cmdline += ["-sysdir", args.sysdir] if args.sysdir else [] + cmdline += ["-system", args.system] if args.system else [] + cmdline += ["-vendor", args.vendor] if args.vendor else [] + cmdline += ["-kernel", args.kernel] if args.kernel else [] + cmdline += ["-ramdisk", args.ramdisk] if args.ramdisk else [] + cmdline += ["-data", args.data] if args.data else [] + cmdline += ["-encryption-key", args.encryption_key] if args.encryption_key else [] + cmdline += ["-cache", args.cache] if args.cache else [] + cmdline += ["-cache-size", str(args.cache_size)] if args.cache_size else [] + cmdline += ["-no-cache"] if args.no_cache else [] + cmdline += ["-cores", str(args.cores)] if args.cores else [] + + # Boot/Snapshot Control + cmdline += ["-delay-adb"] if args.delay_adb else [] + cmdline += ["-quit-after-boot", str(args.quit_after_boot)] if args.quit_after_boot else [] + cmdline += ["-force-snapshot-load"] if args.force_snapshot_load else [] + cmdline += ["-no-snapshot-update-time"] if args.no_snapshot_update_time else [] + cmdline += ["-qcow2-for-userdata"] if args.qcow2_for_userdata else [] + + # Network/Communication + cmdline += ["-wifi-client-port", str(args.wifi_client_port)] if args.wifi_client_port else [] + cmdline += ["-wifi-server-port", str(args.wifi_server_port)] if args.wifi_server_port else [] + cmdline += ["-net-tap", args.net_tap] if args.net_tap else [] + cmdline += ["-net-tap-script-up", args.net_tap_script_up] if args.net_tap_script_up else [] + cmdline += ["-net-tap-script-down", args.net_tap_script_down] if args.net_tap_script_down else [] + + # Display/UI + cmdline += ["-dpi-device", str(args.dpi_device)] if args.dpi_device else [] + cmdline += ["-fixed-scale"] if args.fixed_scale else [] + cmdline += ["-vsync-rate", str(args.vsync_rate)] if args.vsync_rate else [] + for name, file in args.virtualscene_poster.items(): + cmdline += ["-virtualscene-poster", f"{name}={file}"] + + # Audio + cmdline += ["-no-audio"] if args.no_audio else [] + cmdline += ["-audio", args.audio] if args.audio else [] + cmdline += ["-allow-host-audio"] if args.allow_host_audio else [] + + # Locale/Language + cmdline += ["-change-language", args.change_language] if args.change_language else [] + cmdline += ["-change-country", args.change_country] if args.change_country else [] + cmdline += ["-change-locale", args.change_locale] if args.change_locale else [] + + # Additional Display/UI options + cmdline += ["-qt-hide-window"] if args.qt_hide_window else [] + for display in args.multidisplay: + cmdline += ["-multidisplay", ",".join(map(str, display))] + cmdline += ["-no-location-ui"] if args.no_location_ui else [] + cmdline += ["-no-hidpi-scaling"] if args.no_hidpi_scaling else [] + cmdline += ["-no-mouse-reposition"] if args.no_mouse_reposition else [] + + # Additional System Control + cmdline += ["-detect-image-hang"] if args.detect_image_hang else [] + for feature, enabled in args.feature.items(): + cmdline += ["-feature", f"{feature}={'on' if enabled else 'off'}"] + cmdline += ["-icc-profile", args.icc_profile] if args.icc_profile else [] + cmdline += ["-sim-access-rules-file", args.sim_access_rules_file] if args.sim_access_rules_file else [] + cmdline += ["-phone-number", args.phone_number] if args.phone_number else [] + + # Additional Network/gRPC options + cmdline += ["-grpc-port", str(args.grpc_port)] if args.grpc_port else [] + cmdline += ["-grpc-tls-key", args.grpc_tls_key] if args.grpc_tls_key else [] + cmdline += ["-grpc-tls-cert", args.grpc_tls_cert] if args.grpc_tls_cert else [] + cmdline += ["-grpc-tls-ca", args.grpc_tls_ca] if args.grpc_tls_ca else [] + cmdline += ["-grpc-use-token"] if args.grpc_use_token else [] + cmdline += ["-grpc-use-jwt"] if args.grpc_use_jwt else [] + + # Existing arguments + cmdline += ["-no-boot-anim"] if args.no_boot_anim else [] + cmdline += ["-no-snapshot"] if args.no_snapshot else [] + cmdline += ["-no-snapshot-load"] if args.no_snapshot_load else [] + cmdline += ["-no-snapshot-save"] if args.no_snapshot_save else [] + cmdline += ["-no-window"] if args.no_window else [] + cmdline += ["-gpu", args.gpu] if args.gpu else [] + cmdline += ["-memory", str(args.memory)] if args.memory else [] + cmdline += ["-partition-size", str(args.partition_size)] if args.partition_size else [] + cmdline += ["-sdcard", args.sdcard] if args.sdcard else [] + cmdline += ["-skin", args.skin] if args.skin else [] + cmdline += ["-timezone", args.timezone] if args.timezone else [] + cmdline += ["-verbose"] if args.verbose else [] + cmdline += ["-writable-system"] if args.writable_system else [] + cmdline += ["-show-kernel"] if args.show_kernel else [] + cmdline += ["-logcat", args.logcat] if args.logcat else [] + cmdline += ["-camera-back", args.camera_back] if args.camera_back else [] + cmdline += ["-camera-front", args.camera_front] if args.camera_front else [] + cmdline += ["-selinux", args.selinux] if args.selinux else [] + cmdline += ["-dns-server", args.dns_server] if args.dns_server else [] + cmdline += ["-http-proxy", args.http_proxy] if args.http_proxy else [] + cmdline += ["-netdelay", args.netdelay] if args.netdelay else [] + cmdline += ["-netspeed", args.netspeed] if args.netspeed else [] + cmdline += ["-port", str(args.port)] if args.port else [] + cmdline += ["-tcpdump", args.tcpdump] if args.tcpdump else [] + cmdline += ["-accel", args.accel] if args.accel else [] + cmdline += ["-engine", args.engine] if args.engine else [] + cmdline += ["-no-accel"] if args.no_accel else [] + cmdline += ["-gpu", args.gpu_mode] if args.gpu_mode else [] + cmdline += ["-wipe-data"] if args.wipe_data else [] + cmdline += ["-debug", args.debug_tags] if args.debug_tags else [] + + # Advanced System Arguments + cmdline += ["-acpi-config", args.acpi_config] if args.acpi_config else [] + for key, value in args.append_userspace_opt.items(): + cmdline += ["-append-userspace-opt", f"{key}={value}"] + cmdline += ["-guest-angle"] if args.guest_angle else [] + if args.usb_passthrough: + cmdline += ["-usb-passthrough"] + list(map(str, args.usb_passthrough)) + cmdline += ["-save-path", args.save_path] if args.save_path else [] + cmdline += ["-waterfall", args.waterfall] if args.waterfall else [] + cmdline += ["-restart-when-stalled"] if args.restart_when_stalled else [] + + # Add any remaining QEMU arguments at the end + if args.qemu_args: + cmdline += ["-qemu"] + args.qemu_args + + return cmdline + + @export + def on(self) -> None: + if self._process is not None: + self.logger.warning("Android emulator is already powered on, ignoring request.") + return + + # Create the emulator command line options + cmdline = self._make_emulator_command() + + self.logger.info(f"Starting Android emulator with command: {' '.join(cmdline)}") + self._process = subprocess.Popen( + cmdline, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=False, # Keep as bytes for proper encoding handling + ) + + # Process logs in separate threads + self._log_thread = threading.Thread(target=self._process_logs, args=(self._process.stdout,), daemon=True) + self._stderr_thread = threading.Thread( + target=self._process_logs, args=(self._process.stderr, True), daemon=True + ) + self._log_thread.start() + self._stderr_thread.start() + + @export + def off(self) -> None: + if self._process is not None: + # First, attempt to power off emulator using adb command + try: + subprocess.run( + [self.parent.adb.adb_path, "-s", f"emulator-{self.parent.emulator.port}", "emu", "kill"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to power off Android emulator: {e}") + # If the adb command fails, kill the process directly + except Exception as e: + self.logger.error(f"Unexpected error while powering off Android emulator: {e}") + + # Wait up to 20 seconds for process to terminate after sending emu kill + try: + self._process.wait(timeout=30) + except TimeoutExpired: + self.logger.warning("Android emulator did not exit within 30 seconds after 'emu kill' command") + # Attempt to kill the process directly + try: + self.logger.warning("Attempting to kill Android emulator process directly.") + self._process.kill() + except ProcessLookupError: + self.logger.warning("Android emulator process not found, it may have already exited.") + + # Attempt to join the logging threads + try: + if self._log_thread is not None: + self._log_thread.join(timeout=2) + if self._stderr_thread is not None: + self._stderr_thread.join(timeout=2) + except TimeoutError: + self.logger.warning("Log processing threads did not exit cleanly") + + # Clean up process and threads + self._process = None + self._log_thread = None + self._stderr_thread = None + self.logger.info("Android emulator powered off.") + else: + self.logger.warning("Android emulator is already powered off, ignoring request.") + + @export + async def read(self) -> AsyncGenerator[PowerReading, None]: + pass + + def close(self): + self.off() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py new file mode 100644 index 000000000..7e8a9b1b8 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py @@ -0,0 +1,187 @@ +import os +from typing import Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, Field, model_validator + + +class AdbOptions(BaseModel): + """ + Holds the options for the ADB server. + + Attributes: + host (str): The host address for the ADB server. Default is + """ + + adb_path: str = Field(default="adb") + host: str = Field(default="127.0.0.1") + port: int = Field(default=5037) + + +class EmulatorOptions(BaseModel): + """ + Pydantic model for Android Emulator CLI arguments. + See original docstring for full documentation. + """ + + # Core Configuration + emulator_path: str = Field(default="emulator") + avd: str = Field(default="default") + cores: Optional[int] = Field(default=4, ge=1) + memory: int = Field(default=2048, ge=1024, le=16384) + + # System Images and Storage + sysdir: Optional[str] = None + system: Optional[str] = None + vendor: Optional[str] = None + kernel: Optional[str] = None + ramdisk: Optional[str] = None + data: Optional[str] = None + sdcard: Optional[str] = None + partition_size: int = Field(default=2048, ge=512, le=16384) + writable_system: bool = False + + # Cache Configuration + cache: Optional[str] = None + cache_size: Optional[int] = Field(default=None, ge=16) + no_cache: bool = False + + # Snapshot Management + no_snapshot: bool = False + no_snapshot_load: bool = False + no_snapshot_save: bool = False + snapshot: Optional[str] = None + force_snapshot_load: bool = False + no_snapshot_update_time: bool = False + qcow2_for_userdata: bool = False + + # Display and GPU + no_window: bool = True + gpu: Literal["auto", "host", "swiftshader", "angle", "guest"] = "auto" + gpu_mode: Literal["auto", "host", "swiftshader", "angle", "guest"] = "auto" + no_boot_anim: bool = False + skin: Optional[str] = None + dpi_device: Optional[int] = Field(default=None, ge=0) + fixed_scale: bool = False + scale: str = "1" + vsync_rate: Optional[int] = Field(default=None, ge=1) + qt_hide_window: bool = False + multidisplay: List[Tuple[int, int, int, int, int]] = [] + no_location_ui: bool = False + no_hidpi_scaling: bool = False + no_mouse_reposition: bool = False + virtualscene_poster: Dict[str, str] = {} + guest_angle: bool = False + + # Network Configuration + wifi_client_port: Optional[int] = Field(default=None, ge=1, le=65535) + wifi_server_port: Optional[int] = Field(default=None, ge=1, le=65535) + net_tap: Optional[str] = None + net_tap_script_up: Optional[str] = None + net_tap_script_down: Optional[str] = None + dns_server: Optional[str] = None + http_proxy: Optional[str] = None + netdelay: Literal["none", "umts", "gprs", "edge", "hscsd"] = "none" + netspeed: Literal["full", "gsm", "hscsd", "gprs", "edge", "umts"] = "full" + port: int = Field(default=5554, ge=5554, le=5682) + + # Audio Configuration + no_audio: bool = False + audio: Optional[str] = None + allow_host_audio: bool = False + + # Camera Configuration + camera_back: Literal["emulated", "webcam0", "none"] = "emulated" + camera_front: Literal["emulated", "webcam0", "none"] = "emulated" + + # Localization + timezone: Optional[str] = None + change_language: Optional[str] = None + change_country: Optional[str] = None + change_locale: Optional[str] = None + + # Security + encryption_key: Optional[str] = None + selinux: Optional[Literal["enforcing", "permissive", "disabled"]] = None + + # Hardware Acceleration + accel: Literal["auto", "off", "on"] = "auto" + no_accel: bool = False + engine: Literal["auto", "qemu", "swiftshader"] = "auto" + + # Debugging and Monitoring + verbose: bool = False + show_kernel: bool = False + logcat: Optional[str] = None + debug_tags: Optional[str] = None + tcpdump: Optional[str] = None + detect_image_hang: bool = False + save_path: Optional[str] = None + + # gRPC Configuration + grpc_port: Optional[int] = Field(default=None, ge=1, le=65535) + grpc_tls_key: Optional[str] = None + grpc_tls_cert: Optional[str] = None + grpc_tls_ca: Optional[str] = None + grpc_use_token: bool = False + grpc_use_jwt: bool = True + + # Advanced System Configuration + acpi_config: Optional[str] = None + append_userspace_opt: Dict[str, str] = {} + feature: Dict[str, bool] = {} + icc_profile: Optional[str] = None + sim_access_rules_file: Optional[str] = None + phone_number: Optional[str] = None + usb_passthrough: Optional[Tuple[int, int, int, int]] = None + waterfall: Optional[str] = None + restart_when_stalled: bool = False + wipe_data: bool = False + delay_adb: bool = False + quit_after_boot: Optional[int] = Field(default=None, ge=0) + + # QEMU Configuration + qemu_args: List[str] = [] + props: Dict[str, str] = {} + + @model_validator(mode="after") + def validate_paths(self) -> "EmulatorOptions": + path_fields = [ + "sysdir", + "system", + "vendor", + "kernel", + "ramdisk", + "data", + "encryption_key", + "cache", + "net_tap_script_up", + "net_tap_script_down", + "icc_profile", + "sim_access_rules_file", + "grpc_tls_key", + "grpc_tls_cert", + "grpc_tls_ca", + "acpi_config", + "save_path", + ] + + for name in path_fields: + path = getattr(self, name) + if path and not os.path.exists(path): + raise ValueError(f"Path does not exist: {path}") + + # Validate virtual scene poster paths + for _, path in self.virtualscene_poster.items(): + if not os.path.exists(path): + raise ValueError(f"Virtual scene poster image not found: {path}") + if not path.lower().endswith((".png", ".jpg", ".jpeg")): + raise ValueError(f"Virtual scene poster must be a PNG or JPEG file: {path}") + + # Validate phone number format if provided + if self.phone_number is not None and not self.phone_number.replace("+", "").replace("-", "").isdigit(): + raise ValueError("Phone number must contain only digits, '+', or '-'") + + return self + + class Config: + validate_assignment = True diff --git a/packages/jumpstarter-driver-android/pyproject.toml b/packages/jumpstarter-driver-android/pyproject.toml index 165991ec7..223c11105 100644 --- a/packages/jumpstarter-driver-android/pyproject.toml +++ b/packages/jumpstarter-driver-android/pyproject.toml @@ -8,7 +8,9 @@ license = "Apache-2.0" requires-python = ">=3.11" dependencies = [ "jumpstarter", + "jumpstarter-driver-composite", "jumpstarter-driver-network", + "jumpstarter-driver-power", "asyncclick>=8.1.7.2", "adbutils>=2.8.7", ] diff --git a/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py b/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py index f49825249..bf0d8e0f7 100644 --- a/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py +++ b/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py @@ -241,17 +241,18 @@ async def connect(self): @dataclass(kw_only=True) class WebsocketNetwork(NetworkInterface, Driver): - ''' + """ Handles websocket connections from a given url. - ''' + """ + url: str @exportstream @asynccontextmanager async def connect(self): - ''' + """ Create a websocket connection to `self.url` and srreams its output. - ''' + """ self.logger.info("Connecting to %s", self.url) async with websockets.connect(self.url) as websocket: diff --git a/packages/jumpstarter/jumpstarter/common/utils.py b/packages/jumpstarter/jumpstarter/common/utils.py index 236aa04ef..846e8bd9f 100644 --- a/packages/jumpstarter/jumpstarter/common/utils.py +++ b/packages/jumpstarter/jumpstarter/common/utils.py @@ -45,11 +45,14 @@ def serve(root_device: Driver): ANSI_RESET = "\\[\\e[0m\\]" PROMPT_CWD = "\\W" +BASH_PROMPT = f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}⚡{ANSI_WHITE}{{context}} {ANSI_YELLOW}➤{ANSI_RESET} " +ZSH_PROMPT = "%F{grey}%~ %F{yellow}⚡%F{white}{{context}} %F{yellow}➤%f " + def launch_shell( host: str, context: str, - allow: [str], + allow: list[str], unsafe: bool, *, command: tuple[str, ...] | None = None, @@ -63,11 +66,7 @@ def launch_shell( unsafe: Whether to allow drivers outside of the allow list """ - env = os.environ | { - JUMPSTARTER_HOST: host, - JMP_DRIVERS_ALLOW: "UNSAFE" if unsafe else ",".join(allow), - "PS1": f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}⚡{ANSI_WHITE}{context} {ANSI_YELLOW}➤{ANSI_RESET} ", - } + env = os.environ | {JUMPSTARTER_HOST: host, JMP_DRIVERS_ALLOW: "UNSAFE" if unsafe else ",".join(allow)} if command: process = Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) @@ -76,6 +75,11 @@ def launch_shell( if cmd[0].endswith("bash"): cmd.append("--norc") cmd.append("--noprofile") + env["PS1"] = f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}⚡{ANSI_WHITE}{context} {ANSI_YELLOW}➤{ANSI_RESET} " + elif cmd[0].endswith("zsh"): + cmd.append("-f") + cmd.append("-i") + env["PROMPT"] = "%F{grey}%~ %F{yellow}⚡%F{white}local %F{yellow}➤%f " process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) From c957cd12b17ca827732a67ee0b51b8ed9029b5ae Mon Sep 17 00:00:00 2001 From: Kirk Date: Sun, 4 May 2025 22:35:09 -0400 Subject: [PATCH 05/16] Delete adb-exporter.yaml --- adb-exporter.yaml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 adb-exporter.yaml diff --git a/adb-exporter.yaml b/adb-exporter.yaml deleted file mode 100644 index 40510bd57..000000000 --- a/adb-exporter.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: jumpstarter.dev/v1alpha1 -kind: ExporterConfig -metadata: - name: local-exporter - namespace: default -endpoint: "" -token: "" -export: - adb: - type: jumpstarter_driver_android.driver.AdbServer - config: - port: 3000 From 6fd7d41de1f0ad743eab3993cbf5286af6ead9d2 Mon Sep 17 00:00:00 2001 From: Kirk Date: Mon, 5 May 2025 00:05:51 -0400 Subject: [PATCH 06/16] Add full support for port forwarding of ADB server and scrcpy client --- .../jumpstarter_driver_android/client.py | 102 ++++++++++++++++-- .../driver/__init__.py | 2 + .../jumpstarter_driver_android/driver/adb.py | 4 +- .../driver/emulator.py | 28 +++-- .../driver/scrcpy.py | 12 +++ 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/scrcpy.py diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index c1ffc9b36..bc431b50c 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -1,7 +1,10 @@ +import errno import os +import socket import subprocess import sys from contextlib import contextmanager +from threading import Event from typing import Generator import adbutils @@ -12,13 +15,29 @@ from jumpstarter.client import DriverClient -class AdbClient(DriverClient): - """Power client for controlling power devices.""" +class AdbClientBase(DriverClient): + """ + Base class for ADB clients. This class provides a context manager to + create an ADB client and forward the ADB server address and port. + """ + + def _check_port_in_use(self, host: str, port: int) -> bool: + # Check if port is already bound + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind((host, port)) + except socket.error as e: + if e.errno == errno.EADDRINUSE: + return True + finally: + sock.close() + return False @contextmanager def forward_adb(self, host: str, port: int) -> Generator[str, None, None]: """ Port-forward remote ADB server to local host and port. + If the port is already bound, yields the existing address instead. Args: host (str): The local host to forward to. @@ -50,6 +69,10 @@ def adb_client(self, host: str = "127.0.0.1", port: int = 5038) -> Generator[adb client = adbutils.AdbClient(host=addr[0], port=int(addr[1])) yield client + +class AdbClient(AdbClientBase): + """Power client for controlling power devices.""" + def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @click.option("host", "-H", default="127.0.0.1", show_default=True, help="Local adb host to forward to.") @@ -109,16 +132,21 @@ def adb( ] for arg in args: if arg in unsupported_commands: - raise click.UsageError(f"ADB command '{arg}' is not supported by the Jumpstarter ADB client") + raise click.UsageError(f"The adb command '{arg}' is not supported by the Jumpstarter ADB client") if "start-server" in args: remote_port = int(self.call("start_server")) - click.echo(f"ADB server started on remote port exporter:{remote_port}") + click.echo(f"Remote adb server started on remote port exporter:{remote_port}") return 0 - if "kill-server" in args: + elif "kill-server" in args: remote_port = int(self.call("kill_server")) - click.echo(f"ADB server killed on remote port exporter:{remote_port}") + click.echo(f"Remote adb server killed on remote port exporter:{remote_port}") return 0 + elif "forward-adb" in args: + # Port is available, proceed with forwarding + with self.forward_adb(host, port) as addr: + click.echo(f"Remote adb server forwarded to {addr[0]}:{addr[1]}") + Event().wait() # Forward the ADB server address and port and call ADB executable with args with self.forward_adb(host, port) as addr: @@ -133,6 +161,68 @@ def adb( return adb +class ScrcpyClient(AdbClientBase): + """Scrcpy client for controlling Android devices/emulators.""" + + def cli(self): + @click.command(context_settings={"ignore_unknown_options": True}) + @click.option("host", "-H", default="127.0.0.1", show_default=True, help="Local adb host to forward to.") + @click.option("port", "-P", type=int, default=5038, show_default=True, help="Local adb port to forward to.") + @click.option( + "--scrcpy", + default="scrcpy", + show_default=True, + help="Path to the scrcpy executable", + ) + @click.argument("args", nargs=-1) + def scrcpy( + host: str, + port: int, + scrcpy: str, + args: tuple[str, ...], + ): + """ + Run scrcpy using a local executable against the remote adb server. This command is a wrapper around + the scrcpy command-line tool. It allows you to run scrcpy against a remote Android device through + an ADB server tunneled via Jumpstarter. + + When executing this command, the adb server address and port are forwarded to the local scrcpy executable. + The adb server socket path is set in the environment variable ADB_SERVER_SOCKET, allowing scrcpy to + communicate with the remote adb server. + + Most command line arguments are passed directly to the scrcpy executable. + """ + # Unsupported scrcpy arguments that depend on direct adb server management + unsupported_args = [ + "--connect", + "-c", + "--serial", + "-s", + "--select-usb", + "--select-tcpip", + ] + + for arg in args: + for unsupported in unsupported_args: + if arg.startswith(unsupported): + raise click.UsageError( + f"Scrcpy argument '{unsupported}' is not supported by the Jumpstarter scrcpy client" + ) + + # Forward the ADB server address and port and call scrcpy executable with args + with self.forward_adb(host, port) as addr: + # Scrcpy uses ADB_SERVER_SOCKET environment variable + socket_path = f"tcp:{addr[0]}:{addr[1]}" + env = os.environ | { + "ADB_SERVER_SOCKET": socket_path, + } + cmd = [scrcpy, *args] + process = subprocess.Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) + return process.wait() + + return scrcpy + + class AndroidClient(CompositeClient): """Generic Android client for controlling Android devices/emulators.""" diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py index d49bebeb5..d03f8f062 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py @@ -1,6 +1,7 @@ from .adb import AdbServer from .emulator import AndroidEmulator, AndroidEmulatorPower from .options import AdbOptions, EmulatorOptions +from .scrcpy import Scrcpy __all__ = [ "AdbServer", @@ -8,4 +9,5 @@ "AndroidEmulatorPower", "AdbOptions", "EmulatorOptions", + "Scrcpy", ] diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py index 1d44cea00..279a2882a 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py @@ -105,5 +105,5 @@ def __post_init__(self): except subprocess.CalledProcessError as e: self.logger.error(f"Failed to execute adb: {e}") - def close(self): - self.kill_server() + # def close(self): + # self.kill_server() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py index 2bba0c844..bacd773c1 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -1,3 +1,4 @@ +import os import subprocess import threading from dataclasses import dataclass, field @@ -10,6 +11,7 @@ from jumpstarter_driver_android.driver.adb import AdbServer from jumpstarter_driver_android.driver.options import AdbOptions, EmulatorOptions +from jumpstarter_driver_android.driver.scrcpy import Scrcpy from jumpstarter.driver import Driver, export @@ -31,6 +33,7 @@ def client(cls) -> str: def __init__(self, **kwargs): self.adb = AdbOptions.model_validate(kwargs.get("adb", {})) self.emulator = EmulatorOptions.model_validate(kwargs.get("emulator", {})) + self.log_level = kwargs.get("log_level", "INFO") if hasattr(super(), "__init__"): super().__init__() @@ -38,8 +41,11 @@ def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - self.children["adb"] = AdbServer(host=self.adb.host, port=self.adb.port, adb_path=self.adb.adb_path) - self.children["power"] = AndroidEmulatorPower(parent=self) + self.children["adb"] = AdbServer( + host=self.adb.host, port=self.adb.port, adb_path=self.adb.adb_path, log_level=self.log_level + ) + self.children["scrcpy"] = Scrcpy(host=self.adb.host, port=self.adb.port, log_level=self.log_level) + self.children["power"] = AndroidEmulatorPower(parent=self, log_level=self.log_level) @dataclass(kw_only=True) @@ -242,17 +248,25 @@ def on(self) -> None: self._stderr_thread.start() @export - def off(self) -> None: - if self._process is not None: + def off(self) -> None: # noqa: C901 + if self._process is not None and self._process.returncode is None: # First, attempt to power off emulator using adb command try: - subprocess.run( + result = subprocess.run( [self.parent.adb.adb_path, "-s", f"emulator-{self.parent.emulator.port}", "emu", "kill"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env={"ANDROID_ADB_SERVER_PORT": str(self.parent.adb.port), **dict(os.environ)}, ) + # Print output and errors as debug + for line in result.stdout.splitlines(): + if line.strip(): + self.logger.debug(line) + for line in result.stderr.splitlines(): + if line.strip(): + self.logger.debug(line) except subprocess.CalledProcessError as e: self.logger.error(f"Failed to power off Android emulator: {e}") # If the adb command fails, kill the process directly @@ -261,9 +275,9 @@ def off(self) -> None: # Wait up to 20 seconds for process to terminate after sending emu kill try: - self._process.wait(timeout=30) + self._process.wait(timeout=20) except TimeoutExpired: - self.logger.warning("Android emulator did not exit within 30 seconds after 'emu kill' command") + self.logger.warning("Android emulator did not exit within 20 seconds after 'emu kill' command") # Attempt to kill the process directly try: self.logger.warning("Attempting to kill Android emulator process directly.") diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/scrcpy.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/scrcpy.py new file mode 100644 index 000000000..a38a255a0 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/scrcpy.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from jumpstarter_driver_network.driver import TcpNetwork + + +@dataclass(kw_only=True) +class Scrcpy(TcpNetwork): + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_android.client.ScrcpyClient" + + pass From ee8759ab5f01c0acb754cf7c65458da99dc6f2c1 Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 08:35:37 -0400 Subject: [PATCH 07/16] Add environment variables option to Android emulator --- .../jumpstarter_driver_android/client.py | 11 +++++++---- .../jumpstarter_driver_android/driver/emulator.py | 9 +++++++++ .../jumpstarter_driver_android/driver/options.py | 3 +++ uv.lock | 4 ++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index bc431b50c..7892b5f75 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -71,7 +71,7 @@ def adb_client(self, host: str = "127.0.0.1", port: int = 5038) -> Generator[adb class AdbClient(AdbClientBase): - """Power client for controlling power devices.""" + """ADB client for interacting with Android devices.""" def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @@ -101,6 +101,8 @@ def adb( args: tuple[str, ...], ): """ + Run adb using a local executable against the remote adb server. + Run commands using a local adb executable against the remote adb server. This command is a wrapper around the adb command-line tool. It allows you to run adb commands against a remote ADB server tunneled through Jumpstarter. @@ -182,9 +184,10 @@ def scrcpy( args: tuple[str, ...], ): """ - Run scrcpy using a local executable against the remote adb server. This command is a wrapper around - the scrcpy command-line tool. It allows you to run scrcpy against a remote Android device through - an ADB server tunneled via Jumpstarter. + Run scrcpy using a local executable against the remote adb server. + + This command is a wrapper around the scrcpy command-line tool. It allows you to run scrcpy + against a remote Android device through an ADB server tunneled via Jumpstarter. When executing this command, the adb server address and port are forwarded to the local scrcpy executable. The adb server socket path is set in the environment variable ADB_SERVER_SOCKET, allowing scrcpy to diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py index bacd773c1..a7b497288 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -230,6 +230,14 @@ def on(self) -> None: # Create the emulator command line options cmdline = self._make_emulator_command() + # Prepare environment variables + env = dict(os.environ) + env.update(self.parent.emulator.env) + + self.logger.info("Starting with environment variables:") + for key, value in env.items(): + self.logger.info(f"{key}: {value}") + self.logger.info(f"Starting Android emulator with command: {' '.join(cmdline)}") self._process = subprocess.Popen( cmdline, @@ -237,6 +245,7 @@ def on(self) -> None: stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=False, # Keep as bytes for proper encoding handling + env=env, ) # Process logs in separate threads diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py index 7e8a9b1b8..eba8492e6 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py @@ -143,6 +143,9 @@ class EmulatorOptions(BaseModel): qemu_args: List[str] = [] props: Dict[str, str] = {} + # Additional environment variables + env: Dict[str, str] = {} + @model_validator(mode="after") def validate_paths(self) -> "EmulatorOptions": path_fields = [ diff --git a/uv.lock b/uv.lock index e7a947cea..a224bba80 100644 --- a/uv.lock +++ b/uv.lock @@ -1206,7 +1206,9 @@ dependencies = [ { name = "adbutils" }, { name = "asyncclick" }, { name = "jumpstarter" }, + { name = "jumpstarter-driver-composite" }, { name = "jumpstarter-driver-network" }, + { name = "jumpstarter-driver-power" }, ] [package.dev-dependencies] @@ -1221,7 +1223,9 @@ requires-dist = [ { name = "adbutils", specifier = ">=2.8.7" }, { name = "asyncclick", specifier = ">=8.1.7.2" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, { name = "jumpstarter-driver-network", editable = "packages/jumpstarter-driver-network" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, ] [package.metadata.requires-dev] From b5472c448434e3597d93c8a0345285d72d71623b Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 08:39:28 -0400 Subject: [PATCH 08/16] Update install instructions --- packages/jumpstarter-driver-android/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jumpstarter-driver-android/README.md b/packages/jumpstarter-driver-android/README.md index 0a7d400e4..a86077e14 100644 --- a/packages/jumpstarter-driver-android/README.md +++ b/packages/jumpstarter-driver-android/README.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install jumpstarter-driver-android +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-android ``` ## Configuration From d583165844e83a0ba1f153139e008863d5ceb8f7 Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 13:14:09 -0400 Subject: [PATCH 09/16] Update documentation and refactor base android driver --- packages/jumpstarter-driver-android/README.md | 320 +++++++++++++++++- .../jumpstarter_driver_android/client.py | 37 +- .../driver/device.py | 40 +++ .../driver/emulator.py | 32 +- 4 files changed, 383 insertions(+), 46 deletions(-) create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py diff --git a/packages/jumpstarter-driver-android/README.md b/packages/jumpstarter-driver-android/README.md index a86077e14..d8d677ff4 100644 --- a/packages/jumpstarter-driver-android/README.md +++ b/packages/jumpstarter-driver-android/README.md @@ -2,24 +2,330 @@ `jumpstarter-driver-android` provides ADB and Android emulator functionality for Jumpstarter. +This functionality enables you to write test cases and custom drivers for physical +and virtual Android devices running in CI, on the edge, or on your desk. + ## Installation ```bash pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-android ``` -## Configuration +## Drivers + +This package provides the following drivers: + +### `AdbServer` + +This driver can start, stop, and forward an ADB daemon server running on the exporter. + +This driver implements the `TcpNetwork` driver from `jumpstarter-driver-network` to support forwarding the ADB connection through Jumpstarter. + +#### Configuration + +ADB server configuration example: + +```yaml +export: + adb: + type: jumpstarter_driver_android.driver.AdbServer + config: + port: 1234 # Specify a custom port to run ADB on and forward +``` + +ADB configuration parameters: + +| Parameter | Description | Default Value | Optional | Supported Values | +| ---------- | -------------------------------- | ------------- | -------- | ----------------------- | +| `adb_path` | Path to the ADB executable. | `"adb"` | Yes | Any valid path | +| `host` | Host address for the ADB server. | `"127.0.0.1"` | Yes | Any valid IP address. | +| `port` | Port for the ADB server. | `5037` | Yes | `1` ≤ Integer ≤ `65535` | + +### `Scrcpy` + +This driver is a stub `TcpNetwork` driver to provide [`scrcpy`](https://github.com/Genymobile/scrcpy) support by managing its own ADB forwarding internally. This +allows developers to access a device via `scrcpy` without full ADB access if needed. + +#### Configuration + +Scrcpy configuration example: + +```yaml +export: + adb: + type: jumpstarter_driver_android.driver.Scrcpy + config: + port: 1234 # Specify a custom port to look for ADB on +``` + +### `AndroidDevice` -Example configuration: +This top-level composite driver provides an `adb` and `scrcpy` interfaces +to remotely control an Android device connected to the exporter. + +#### Configuration + +Android device configuration example: ```yaml export: - composite: - type: jumpstarter_driver_android.driver.Adb + android: + type: jumpstarter_driver_android.driver.AndroidDevice config: - # Add required config parameters here + adb: + port: 1234 # Specify a custom port to run ADB on ``` -## API Reference +#### Children + +- `adb` - `AdbServer` instance configured to tunnel the Android devices ADB connection. +- `scrcpy` - `Scrcpy` instance to remotely access an Android device's screen. + +### `AndroidEmulator` + +This composite driver extends the base `AndroidDevice` driver to provide a `power` +interface to remotely start/top an android emulator instance running on the exporter. + +#### Children + +- `adb` - `AdbServer` instance configured to tunnel the Android devices ADB connection. +- `scrcpy` - `Scrcpy` instance to remotely access an Android device's screen. +- `power` - `AndroidEmulatorPower` instance to turn on/off an emualtor instance. + +#### Configuration + +Android emulator configuration example: + +```yaml +export: + android: + type: jumpstarter_driver_android.driver.AndroidEmulator + config: + adb: # Takes same parameters as the `AdbServer` driver + port: 1234 # Specify a custom port to run ADB on + emulator: + avd: "Pixel_9_Pro" + cores: 4 + memory: 2048 + # Add additional parameters as needed +``` + +Emulator configuration parameters: + +| Parameter | Description | Default Value | Optional | Supported Values | +| ------------------------- | -------------------------------------------------- | ------------- | -------- | ---------------------------------------------------------- | +| `emulator_path` | Path to the emulator executable. | `"emulator"` | Yes | Any valid path | +| `avd` | Specifies the Android Virtual Device (AVD) to use. | `"default"` | Yes | Any valid AVD name | +| `cores` | Number of CPU cores to allocate. | `4` | Yes | Integer ≥ `1` | +| `memory` | Amount of RAM (in MB) to allocate. | `2048` | Yes | `1024` ≤ Integer ≤ 16384 | +| `sysdir` | Path to the system directory. | `null` | Yes | Any valid path | +| `system` | Path to the system image. | `null` | Yes | Any valid path | +| `vendor` | Path to the vendor image. | `null` | Yes | Any valid path | +| `kernel` | Path to the kernel image. | `null` | Yes | Any valid path | +| `ramdisk` | Path to the ramdisk image. | `null` | Yes | Any valid path | +| `data` | Path to the data partition. | `null` | Yes | Any valid path | +| `sdcard` | Path to the SD card image. | `null` | Yes | Any valid path | +| `partition_size` | Size of the system partition (in MB). | `2048` | Yes | `512` ≤ Integer ≤ `16384` | +| `writable_system` | Enables writable system partition. | `false` | Yes | `true`, `false` | +| `cache` | Path to the cache partition. | `null` | Yes | Any valid path | +| `cache_size` | Size of the cache partition (in MB). | `null` | Yes | Integer ≥ `16` | +| `no_cache` | Disables the cache partition. | `false` | Yes | `true`, `false` | +| `no_snapshot` | Disables snapshots. | `false` | Yes | `true`, `false` | +| `no_snapshot_load` | Prevents loading snapshots. | `false` | Yes | `true`, `false` | +| `no_snapshot_save` | Prevents saving snapshots. | `false` | Yes | `true`, `false` | +| `snapshot` | Specifies a snapshot to load. | `null` | Yes | Any valid path | +| `force_snapshot_load` | Forces loading of the specified snapshot. | `false` | Yes | `true`, `false` | +| `no_snapshot_update_time` | Prevents updating snapshot timestamps. | `false` | Yes | `true`, `false` | +| `qcow2_for_userdata` | Enables QCOW2 format for userdata. | `false` | Yes | `true`, `false` | +| `no_window` | Runs the emulator without a graphical window. | `true` | Yes | `true`, `false` | +| `gpu` | Specifies the GPU mode. | `"auto"` | Yes | `"auto"`, `"host"`, `"swiftshader"`, `"angle"`, `"guest"` | +| `gpu_mode` | Specifies the GPU rendering mode. | `"auto"` | Yes | `"auto"`, `"host"`, `"swiftshader"`, `"angle"`, `"guest"` | +| `no_boot_anim` | Disables the boot animation. | `false` | Yes | `true`, `false` | +| `skin` | Specifies the emulator skin. | `null` | Yes | Any valid path | +| `dpi_device` | Sets the screen DPI. | `null` | Yes | Integer ≥ 0 | +| `fixed_scale` | Enables fixed scaling. | `false` | Yes | `true`, `false` | +| `scale` | Sets the emulator scale. | `"1"` | Yes | Any valid scale | +| `vsync_rate` | Sets the vertical sync rate. | `null` | Yes | Integer ≥ 1 | +| `qt_hide_window` | Hides the emulator window in Qt. | `false` | Yes | `true`, `false` | +| `multidisplay` | Configures multiple displays. | `[]` | Yes | List of tuples | +| `no_location_ui` | Disables the location UI. | `false` | Yes | `true`, `false` | +| `no_hidpi_scaling` | Disables HiDPI scaling. | `false` | Yes | `true`, `false` | +| `no_mouse_reposition` | Disables mouse repositioning. | `false` | Yes | `true`, `false` | +| `virtualscene_poster` | Configures virtual scene posters. | `{}` | Yes | Dictionary | +| `guest_angle` | Enables guest ANGLE. | `false` | Yes | `true`, `false` | +| `wifi_client_port` | Port for Wi-Fi client. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `wifi_server_port` | Port for Wi-Fi server. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `net_tap` | Configures network TAP. | `null` | Yes | Any valid path | +| `net_tap_script_up` | Script to run when TAP is up. | `null` | Yes | Any valid path | +| `net_tap_script_down` | Script to run when TAP is down. | `null` | Yes | Any valid path | +| `dns_server` | Specifies the DNS server. | `null` | Yes | Any valid IP | +| `http_proxy` | Configures the HTTP proxy. | `null` | Yes | Any valid proxy | +| `netdelay` | Configures network delay. | `"none"` | Yes | `"none"`, `"umts"`, `"gprs"`, `"edge"`, `"hscsd"` | +| `netspeed` | Configures network speed. | `"full"` | Yes | `"full"`, `"gsm"`, `"hscsd"`, `"gprs"`, `"edge"`, `"umts"` | +| `port` | Specifies the emulator port. | `5554` | Yes | `5554` ≤ Integer ≤ `5682` | +| `no_audio` | Disables audio in the emulator. | `false` | Yes | `true`, `false` | +| `audio` | Configures audio settings. | `null` | Yes | Any valid path | +| `allow_host_audio` | Enables host audio. | `false` | Yes | `true`, `false` | +| `camera_back` | Configures the back camera. | `"emulated"` | Yes | `"emulated"`, `"webcam0"`, `"none"` | +| `camera_front` | Configures the front camera. | `"emulated"` | Yes | `"emulated"`, `"webcam0"`, `"none"` | +| `timezone` | Sets the emulator's timezone. | `null` | Yes | Any valid timezone | +| `change_language` | Changes the language. | `null` | Yes | Any valid language | +| `change_country` | Changes the country. | `null` | Yes | Any valid country | +| `change_locale` | Changes the locale. | `null` | Yes | Any valid locale | +| `encryption_key` | Configures the encryption key. | `null` | Yes | Any valid path | +| `selinux` | Configures SELinux mode. | `null` | Yes | `"enforcing"`, `"permissive"`, `"disabled"` | +| `accel` | Configures hardware acceleration. | `"auto"` | Yes | `"auto"`, `"off"`, `"on"` | +| `no_accel` | Disables hardware acceleration. | `false` | Yes | `true`, `false` | +| `engine` | Configures the emulator engine. | `"auto"` | Yes | `"auto"`, `"qemu"`, `"swiftshader"` | +| `verbose` | Enables verbose logging. | `false` | Yes | `true`, `false` | +| `show_kernel` | Displays kernel messages. | `false` | Yes | `true`, `false` | +| `logcat` | Configures logcat filters. | `null` | Yes | Any valid filter | +| `debug_tags` | Configures debug tags. | `null` | Yes | Any valid tags | +| `tcpdump` | Configures TCP dump. | `null` | Yes | Any valid path | +| `detect_image_hang` | Detects image hangs. | `false` | Yes | `true`, `false` | +| `save_path` | Configures save path. | `null` | Yes | Any valid path | +| `grpc_port` | Configures gRPC port. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `grpc_tls_key` | Configures gRPC TLS key. | `null` | Yes | Any valid path | +| `grpc_tls_cert` | Configures gRPC TLS certificate. | `null` | Yes | Any valid path | +| `grpc_tls_ca` | Configures gRPC TLS CA. | `null` | Yes | Any valid path | +| `grpc_use_token` | Enables gRPC token usage. | `false` | Yes | `true`, `false` | +| `grpc_use_jwt` | Enables gRPC JWT usage. | `true` | Yes | `true`, `false` | +| `acpi_config` | Configures ACPI settings. | `null` | Yes | Any valid path | +| `append_userspace_opt` | Appends userspace options. | `{}` | Yes | Dictionary | +| `feature` | Configures emulator features. | `{}` | Yes | Dictionary | +| `icc_profile` | Configures ICC profile. | `null` | Yes | Any valid path | +| `sim_access_rules_file` | Configures SIM access rules. | `null` | Yes | Any valid path | +| `phone_number` | Configures phone number. | `null` | Yes | Any valid number | +| `usb_passthrough` | Configures USB passthrough. | `null` | Yes | Tuple of integers | +| `waterfall` | Configures waterfall display. | `null` | Yes | Any valid path | +| `restart_when_stalled` | Restarts emulator when stalled. | `false` | Yes | `true`, `false` | +| `wipe_data` | Wipes user data on startup. | `false` | Yes | `true`, `false` | +| `delay_adb` | Delays ADB startup. | `false` | Yes | `true`, `false` | +| `quit_after_boot` | Quits emulator after boot. | `null` | Yes | Integer ≥ 0 | +| `qemu_args` | Configures QEMU arguments. | `[]` | Yes | List of strings | +| `props` | Configures emulator properties. | `{}` | Yes | Dictionary | +| `env` | Configures environment variables. | `{}` | Yes | Dictionary | + +### `AndroidEmulatorPower` + +This driver implements the `PowerInterface` from the `jumpstarter-driver-power` +package to turn on/off the android emulator running on the exporter. -Add API documentation here. +> ⚠️ **Warning:** This driver should not be used standalone as it does not provide ADB forwarding. + +## Clients + +The Android driver provides the following clients for interacting with Android devices/emulators. + +### `AndroidClient` + +The `AndroidClient` provides a generic composite client for interacting with Android devices. + +#### CLI + +```plain +$ jmp shell --exporter-config ~/.config/jumpstarter/exporters/android-local.yaml + +~/jumpstarter ⚡ local ➤ j android +Usage: j android [OPTIONS] COMMAND [ARGS]... + + Generic composite device + +Options: + --help Show this message and exit. + +Commands: + adb Run adb using a local executable against the remote adb server. + power Generic power + scrcpy Run scrcpy using a local executable against the remote adb server. + +~/repos/jumpstarter ⚡ local ➤ exit +``` + +### `AdbClient` + +The `AdbClient` provides methods to forward the ADB server from an exporter to the client and interact with ADB either through the [`adbutils`](https://github.com/openatx/adbutils) Python package or via the `adb` CLI tool. + +### CLI + +This client provides a wrapper CLI around your local `adb` tool to provide additional +Jumpstarter functionality such as automatic port forwarding and remote control +of the ADB server on the exporter. + +```plain +~/jumpstarter ⚡local ➤ j android adb --help +Usage: j android adb [OPTIONS] [ARGS]... + + Run adb using a local adb binary against the remote adb server. + + This command is a wrapper around the adb command-line tool. It allows you to + run regular adb commands with an automatically forwarded adb server running + on your Jumpstarter exporter. + + When executing this command, the exporter adb daemon is forwarded to a local + port. The adb server address and port are automatically set in the + environment variables ANDROID_ADB_SERVER_ADDRESS and + ANDROID_ADB_SERVER_PORT, respectively. This configures your local adb client + to communicate with the remote adb server. + + Most command line arguments and commands are passed directly to the adb CLI. + However, some arguments and commands are not supported by the Jumpstarter + adb client. These options include: -a, -d, -e, -L, --one-device. + + The following adb commands are also not supported in remote adb + environments: connect, disconnect, reconnect, nodaemon, pair + + When running start-server or kill-server, Jumpstarter will start or kill the + adb server on the exporter. + + Use the forward-adb command to forward the adb server address and port to a + local port manually. + +Options: + -H TEXT Local adb host to forward to. [default: 127.0.0.1] + -P INTEGER Local adb port to forward to. [default: 5038] + --adb TEXT Path to the ADB executable [default: adb] + --help Show this message and exit. +``` + +### API Reference + +```{eval-rst} +.. autoclass:: jumpstarter_driver_android.client.AdbClient() + :members: forward_adb, adb_client +``` + +### `ScrcpyClient` + +The `ScrcpyClient` provides CLI integration with the [`scrcpy`](https://github.com/Genymobile/scrcpy) tool for remotely interacting with physical and virtual Android devices. + +> **Note:** The `scrcpy` CLI tool is required on your client device to use this driver client. + +#### CLI + +Similar to the ADB client, the `ScrcpyClient` also provides a wrapper around +the local `scrcpy` tool to automatically port-forward the ADB connection. + +```plain +~/jumpstarter ⚡local ➤ j android scrcpy --help +Usage: j android scrcpy [OPTIONS] [ARGS]... + + Run scrcpy using a local executable against the remote adb server. + + This command is a wrapper around the scrcpy command-line tool. It allows you + to run scrcpy against a remote Android device through an ADB server tunneled + via Jumpstarter. + + When executing this command, the adb server address and port are forwarded + to the local scrcpy executable. The adb server socket path is set in the + environment variable ADB_SERVER_SOCKET, allowing scrcpy to communicate with + the remote adb server. + + Most command line arguments are passed directly to the scrcpy executable. + +Options: + -H TEXT Local adb host to forward to. [default: 127.0.0.1] + -P INTEGER Local adb port to forward to. [default: 5038] + --scrcpy TEXT Path to the scrcpy executable [default: scrcpy] + --help Show this message and exit. +``` diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index 7892b5f75..c5ca17f86 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -15,6 +15,12 @@ from jumpstarter.client import DriverClient +class AndroidClient(CompositeClient): + """Generic Android client for controlling Android devices/emulators.""" + + pass + + class AdbClientBase(DriverClient): """ Base class for ADB clients. This class provides a context manager to @@ -101,23 +107,26 @@ def adb( args: tuple[str, ...], ): """ - Run adb using a local executable against the remote adb server. + Run adb using a local adb binary against the remote adb server. - Run commands using a local adb executable against the remote adb server. This command is a wrapper around - the adb command-line tool. It allows you to run adb commands against a remote ADB server tunneled through - Jumpstarter. + This command is a wrapper around the adb command-line tool. It allows you to run regular adb commands + with an automatically forwarded adb server running on your Jumpstarter exporter. - When executing this command, the adb server address and port are forwarded to the local ADB executable. The - adb server address and port are set in the environment variables ANDROID_ADB_SERVER_ADDRESS and - ANDROID_ADB_SERVER_PORT, respectively. This allows the local ADB executable to communicate with the remote - adb server. + When executing this command, the exporter adb daemon is forwarded to a local port. The + adb server address and port are automatically set in the environment variables ANDROID_ADB_SERVER_ADDRESS + and ANDROID_ADB_SERVER_PORT, respectively. This configures your local adb client to communicate with the + remote adb server. - Most command line arguments and commands are passed directly to the adb executable. However, some + Most command line arguments and commands are passed directly to the adb CLI. However, some arguments and commands are not supported by the Jumpstarter adb client. These options include: -a, -d, -e, -L, --one-device. - The following adb commands are also not supported: connect, disconnect, + The following adb commands are also not supported in remote adb environments: connect, disconnect, reconnect, nodaemon, pair + + When running start-server or kill-server, Jumpstarter will start or kill the adb server on the exporter. + + Use the forward-adb command to forward the adb server address and port to a local port manually. """ # Throw exception for all unsupported arguments if any([a, d, e, l, one_device]): @@ -164,7 +173,7 @@ def adb( class ScrcpyClient(AdbClientBase): - """Scrcpy client for controlling Android devices/emulators.""" + """Scrcpy client for controlling Android devices remotely.""" def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @@ -224,9 +233,3 @@ def scrcpy( return process.wait() return scrcpy - - -class AndroidClient(CompositeClient): - """Generic Android client for controlling Android devices/emulators.""" - - pass diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py new file mode 100644 index 000000000..2ca4fe694 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import override + +from jumpstarter_driver_android.driver.adb import AdbServer +from jumpstarter_driver_android.driver.options import AdbOptions +from jumpstarter_driver_android.driver.scrcpy import Scrcpy + +from jumpstarter.driver.base import Driver + + +@dataclass(kw_only=True) +class AndroidDevice(Driver): + """ + A base Android device driver composed of the `AdbServer` and `Scrcpy` drivers. + """ + + @classmethod + @override + def client(cls) -> str: + return "jumpstarter_driver_android.client.AndroidClient" + + adb: AdbOptions + disable_scrcpy: bool = False + disable_adb: bool = False + + def __init__(self, **kwargs): + self.adb = AdbOptions.model_validate(kwargs.get("adb", {})) + if hasattr(super(), "__init__"): + super().__init__(**kwargs) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + if not self.disable_adb: + self.children["adb"] = AdbServer( + host=self.adb.host, port=self.adb.port, adb_path=self.adb.adb_path, log_level=self.log_level + ) + if not self.disable_scrcpy: + self.children["scrcpy"] = Scrcpy(host=self.adb.host, port=self.adb.port, log_level=self.log_level) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py index a7b497288..e9f21847a 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -3,48 +3,32 @@ import threading from dataclasses import dataclass, field from subprocess import TimeoutExpired -from typing import IO, AsyncGenerator, Optional, override +from typing import IO, AsyncGenerator, Optional from anyio.abc import Process from jumpstarter_driver_power.common import PowerReading from jumpstarter_driver_power.driver import PowerInterface -from jumpstarter_driver_android.driver.adb import AdbServer -from jumpstarter_driver_android.driver.options import AdbOptions, EmulatorOptions -from jumpstarter_driver_android.driver.scrcpy import Scrcpy +from jumpstarter_driver_android.driver.device import AndroidDevice +from jumpstarter_driver_android.driver.options import EmulatorOptions from jumpstarter.driver import Driver, export @dataclass(kw_only=True) -class AndroidEmulator(Driver): +class AndroidEmulator(AndroidDevice): """ AndroidEmulator class provides an interface to configure and manage an Android Emulator instance. """ - adb: AdbOptions emulator: EmulatorOptions - @classmethod - @override - def client(cls) -> str: - return "jumpstarter_driver_android.client.AndroidClient" - def __init__(self, **kwargs): - self.adb = AdbOptions.model_validate(kwargs.get("adb", {})) self.emulator = EmulatorOptions.model_validate(kwargs.get("emulator", {})) - self.log_level = kwargs.get("log_level", "INFO") - if hasattr(super(), "__init__"): - super().__init__() + super().__init__(**kwargs) def __post_init__(self): - if hasattr(super(), "__post_init__"): - super().__post_init__() - - self.children["adb"] = AdbServer( - host=self.adb.host, port=self.adb.port, adb_path=self.adb.adb_path, log_level=self.log_level - ) - self.children["scrcpy"] = Scrcpy(host=self.adb.host, port=self.adb.port, log_level=self.log_level) + super().__post_init__() self.children["power"] = AndroidEmulatorPower(parent=self, log_level=self.log_level) @@ -234,6 +218,10 @@ def on(self) -> None: env = dict(os.environ) env.update(self.parent.emulator.env) + # Set the ADB server address and port + env["ANDROID_ADB_SERVER_PORT"] = str(self.parent.adb.port) + env["ANDROID_ADB_SERVER_ADDRESS"] = self.parent.adb.host + self.logger.info("Starting with environment variables:") for key, value in env.items(): self.logger.info(f"{key}: {value}") From ccb810fe30d1349fd8d5e8d9b347264e6e73fa45 Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 13:16:43 -0400 Subject: [PATCH 10/16] Add Android docs --- .../reference/package-apis/drivers/android.md | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/source/reference/package-apis/drivers/android.md diff --git a/docs/source/reference/package-apis/drivers/android.md b/docs/source/reference/package-apis/drivers/android.md new file mode 100644 index 000000000..d8d677ff4 --- /dev/null +++ b/docs/source/reference/package-apis/drivers/android.md @@ -0,0 +1,331 @@ +# Android Driver + +`jumpstarter-driver-android` provides ADB and Android emulator functionality for Jumpstarter. + +This functionality enables you to write test cases and custom drivers for physical +and virtual Android devices running in CI, on the edge, or on your desk. + +## Installation + +```bash +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-android +``` + +## Drivers + +This package provides the following drivers: + +### `AdbServer` + +This driver can start, stop, and forward an ADB daemon server running on the exporter. + +This driver implements the `TcpNetwork` driver from `jumpstarter-driver-network` to support forwarding the ADB connection through Jumpstarter. + +#### Configuration + +ADB server configuration example: + +```yaml +export: + adb: + type: jumpstarter_driver_android.driver.AdbServer + config: + port: 1234 # Specify a custom port to run ADB on and forward +``` + +ADB configuration parameters: + +| Parameter | Description | Default Value | Optional | Supported Values | +| ---------- | -------------------------------- | ------------- | -------- | ----------------------- | +| `adb_path` | Path to the ADB executable. | `"adb"` | Yes | Any valid path | +| `host` | Host address for the ADB server. | `"127.0.0.1"` | Yes | Any valid IP address. | +| `port` | Port for the ADB server. | `5037` | Yes | `1` ≤ Integer ≤ `65535` | + +### `Scrcpy` + +This driver is a stub `TcpNetwork` driver to provide [`scrcpy`](https://github.com/Genymobile/scrcpy) support by managing its own ADB forwarding internally. This +allows developers to access a device via `scrcpy` without full ADB access if needed. + +#### Configuration + +Scrcpy configuration example: + +```yaml +export: + adb: + type: jumpstarter_driver_android.driver.Scrcpy + config: + port: 1234 # Specify a custom port to look for ADB on +``` + +### `AndroidDevice` + +This top-level composite driver provides an `adb` and `scrcpy` interfaces +to remotely control an Android device connected to the exporter. + +#### Configuration + +Android device configuration example: + +```yaml +export: + android: + type: jumpstarter_driver_android.driver.AndroidDevice + config: + adb: + port: 1234 # Specify a custom port to run ADB on +``` + +#### Children + +- `adb` - `AdbServer` instance configured to tunnel the Android devices ADB connection. +- `scrcpy` - `Scrcpy` instance to remotely access an Android device's screen. + +### `AndroidEmulator` + +This composite driver extends the base `AndroidDevice` driver to provide a `power` +interface to remotely start/top an android emulator instance running on the exporter. + +#### Children + +- `adb` - `AdbServer` instance configured to tunnel the Android devices ADB connection. +- `scrcpy` - `Scrcpy` instance to remotely access an Android device's screen. +- `power` - `AndroidEmulatorPower` instance to turn on/off an emualtor instance. + +#### Configuration + +Android emulator configuration example: + +```yaml +export: + android: + type: jumpstarter_driver_android.driver.AndroidEmulator + config: + adb: # Takes same parameters as the `AdbServer` driver + port: 1234 # Specify a custom port to run ADB on + emulator: + avd: "Pixel_9_Pro" + cores: 4 + memory: 2048 + # Add additional parameters as needed +``` + +Emulator configuration parameters: + +| Parameter | Description | Default Value | Optional | Supported Values | +| ------------------------- | -------------------------------------------------- | ------------- | -------- | ---------------------------------------------------------- | +| `emulator_path` | Path to the emulator executable. | `"emulator"` | Yes | Any valid path | +| `avd` | Specifies the Android Virtual Device (AVD) to use. | `"default"` | Yes | Any valid AVD name | +| `cores` | Number of CPU cores to allocate. | `4` | Yes | Integer ≥ `1` | +| `memory` | Amount of RAM (in MB) to allocate. | `2048` | Yes | `1024` ≤ Integer ≤ 16384 | +| `sysdir` | Path to the system directory. | `null` | Yes | Any valid path | +| `system` | Path to the system image. | `null` | Yes | Any valid path | +| `vendor` | Path to the vendor image. | `null` | Yes | Any valid path | +| `kernel` | Path to the kernel image. | `null` | Yes | Any valid path | +| `ramdisk` | Path to the ramdisk image. | `null` | Yes | Any valid path | +| `data` | Path to the data partition. | `null` | Yes | Any valid path | +| `sdcard` | Path to the SD card image. | `null` | Yes | Any valid path | +| `partition_size` | Size of the system partition (in MB). | `2048` | Yes | `512` ≤ Integer ≤ `16384` | +| `writable_system` | Enables writable system partition. | `false` | Yes | `true`, `false` | +| `cache` | Path to the cache partition. | `null` | Yes | Any valid path | +| `cache_size` | Size of the cache partition (in MB). | `null` | Yes | Integer ≥ `16` | +| `no_cache` | Disables the cache partition. | `false` | Yes | `true`, `false` | +| `no_snapshot` | Disables snapshots. | `false` | Yes | `true`, `false` | +| `no_snapshot_load` | Prevents loading snapshots. | `false` | Yes | `true`, `false` | +| `no_snapshot_save` | Prevents saving snapshots. | `false` | Yes | `true`, `false` | +| `snapshot` | Specifies a snapshot to load. | `null` | Yes | Any valid path | +| `force_snapshot_load` | Forces loading of the specified snapshot. | `false` | Yes | `true`, `false` | +| `no_snapshot_update_time` | Prevents updating snapshot timestamps. | `false` | Yes | `true`, `false` | +| `qcow2_for_userdata` | Enables QCOW2 format for userdata. | `false` | Yes | `true`, `false` | +| `no_window` | Runs the emulator without a graphical window. | `true` | Yes | `true`, `false` | +| `gpu` | Specifies the GPU mode. | `"auto"` | Yes | `"auto"`, `"host"`, `"swiftshader"`, `"angle"`, `"guest"` | +| `gpu_mode` | Specifies the GPU rendering mode. | `"auto"` | Yes | `"auto"`, `"host"`, `"swiftshader"`, `"angle"`, `"guest"` | +| `no_boot_anim` | Disables the boot animation. | `false` | Yes | `true`, `false` | +| `skin` | Specifies the emulator skin. | `null` | Yes | Any valid path | +| `dpi_device` | Sets the screen DPI. | `null` | Yes | Integer ≥ 0 | +| `fixed_scale` | Enables fixed scaling. | `false` | Yes | `true`, `false` | +| `scale` | Sets the emulator scale. | `"1"` | Yes | Any valid scale | +| `vsync_rate` | Sets the vertical sync rate. | `null` | Yes | Integer ≥ 1 | +| `qt_hide_window` | Hides the emulator window in Qt. | `false` | Yes | `true`, `false` | +| `multidisplay` | Configures multiple displays. | `[]` | Yes | List of tuples | +| `no_location_ui` | Disables the location UI. | `false` | Yes | `true`, `false` | +| `no_hidpi_scaling` | Disables HiDPI scaling. | `false` | Yes | `true`, `false` | +| `no_mouse_reposition` | Disables mouse repositioning. | `false` | Yes | `true`, `false` | +| `virtualscene_poster` | Configures virtual scene posters. | `{}` | Yes | Dictionary | +| `guest_angle` | Enables guest ANGLE. | `false` | Yes | `true`, `false` | +| `wifi_client_port` | Port for Wi-Fi client. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `wifi_server_port` | Port for Wi-Fi server. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `net_tap` | Configures network TAP. | `null` | Yes | Any valid path | +| `net_tap_script_up` | Script to run when TAP is up. | `null` | Yes | Any valid path | +| `net_tap_script_down` | Script to run when TAP is down. | `null` | Yes | Any valid path | +| `dns_server` | Specifies the DNS server. | `null` | Yes | Any valid IP | +| `http_proxy` | Configures the HTTP proxy. | `null` | Yes | Any valid proxy | +| `netdelay` | Configures network delay. | `"none"` | Yes | `"none"`, `"umts"`, `"gprs"`, `"edge"`, `"hscsd"` | +| `netspeed` | Configures network speed. | `"full"` | Yes | `"full"`, `"gsm"`, `"hscsd"`, `"gprs"`, `"edge"`, `"umts"` | +| `port` | Specifies the emulator port. | `5554` | Yes | `5554` ≤ Integer ≤ `5682` | +| `no_audio` | Disables audio in the emulator. | `false` | Yes | `true`, `false` | +| `audio` | Configures audio settings. | `null` | Yes | Any valid path | +| `allow_host_audio` | Enables host audio. | `false` | Yes | `true`, `false` | +| `camera_back` | Configures the back camera. | `"emulated"` | Yes | `"emulated"`, `"webcam0"`, `"none"` | +| `camera_front` | Configures the front camera. | `"emulated"` | Yes | `"emulated"`, `"webcam0"`, `"none"` | +| `timezone` | Sets the emulator's timezone. | `null` | Yes | Any valid timezone | +| `change_language` | Changes the language. | `null` | Yes | Any valid language | +| `change_country` | Changes the country. | `null` | Yes | Any valid country | +| `change_locale` | Changes the locale. | `null` | Yes | Any valid locale | +| `encryption_key` | Configures the encryption key. | `null` | Yes | Any valid path | +| `selinux` | Configures SELinux mode. | `null` | Yes | `"enforcing"`, `"permissive"`, `"disabled"` | +| `accel` | Configures hardware acceleration. | `"auto"` | Yes | `"auto"`, `"off"`, `"on"` | +| `no_accel` | Disables hardware acceleration. | `false` | Yes | `true`, `false` | +| `engine` | Configures the emulator engine. | `"auto"` | Yes | `"auto"`, `"qemu"`, `"swiftshader"` | +| `verbose` | Enables verbose logging. | `false` | Yes | `true`, `false` | +| `show_kernel` | Displays kernel messages. | `false` | Yes | `true`, `false` | +| `logcat` | Configures logcat filters. | `null` | Yes | Any valid filter | +| `debug_tags` | Configures debug tags. | `null` | Yes | Any valid tags | +| `tcpdump` | Configures TCP dump. | `null` | Yes | Any valid path | +| `detect_image_hang` | Detects image hangs. | `false` | Yes | `true`, `false` | +| `save_path` | Configures save path. | `null` | Yes | Any valid path | +| `grpc_port` | Configures gRPC port. | `null` | Yes | `1` ≤ Integer ≤ `65535` | +| `grpc_tls_key` | Configures gRPC TLS key. | `null` | Yes | Any valid path | +| `grpc_tls_cert` | Configures gRPC TLS certificate. | `null` | Yes | Any valid path | +| `grpc_tls_ca` | Configures gRPC TLS CA. | `null` | Yes | Any valid path | +| `grpc_use_token` | Enables gRPC token usage. | `false` | Yes | `true`, `false` | +| `grpc_use_jwt` | Enables gRPC JWT usage. | `true` | Yes | `true`, `false` | +| `acpi_config` | Configures ACPI settings. | `null` | Yes | Any valid path | +| `append_userspace_opt` | Appends userspace options. | `{}` | Yes | Dictionary | +| `feature` | Configures emulator features. | `{}` | Yes | Dictionary | +| `icc_profile` | Configures ICC profile. | `null` | Yes | Any valid path | +| `sim_access_rules_file` | Configures SIM access rules. | `null` | Yes | Any valid path | +| `phone_number` | Configures phone number. | `null` | Yes | Any valid number | +| `usb_passthrough` | Configures USB passthrough. | `null` | Yes | Tuple of integers | +| `waterfall` | Configures waterfall display. | `null` | Yes | Any valid path | +| `restart_when_stalled` | Restarts emulator when stalled. | `false` | Yes | `true`, `false` | +| `wipe_data` | Wipes user data on startup. | `false` | Yes | `true`, `false` | +| `delay_adb` | Delays ADB startup. | `false` | Yes | `true`, `false` | +| `quit_after_boot` | Quits emulator after boot. | `null` | Yes | Integer ≥ 0 | +| `qemu_args` | Configures QEMU arguments. | `[]` | Yes | List of strings | +| `props` | Configures emulator properties. | `{}` | Yes | Dictionary | +| `env` | Configures environment variables. | `{}` | Yes | Dictionary | + +### `AndroidEmulatorPower` + +This driver implements the `PowerInterface` from the `jumpstarter-driver-power` +package to turn on/off the android emulator running on the exporter. + +> ⚠️ **Warning:** This driver should not be used standalone as it does not provide ADB forwarding. + +## Clients + +The Android driver provides the following clients for interacting with Android devices/emulators. + +### `AndroidClient` + +The `AndroidClient` provides a generic composite client for interacting with Android devices. + +#### CLI + +```plain +$ jmp shell --exporter-config ~/.config/jumpstarter/exporters/android-local.yaml + +~/jumpstarter ⚡ local ➤ j android +Usage: j android [OPTIONS] COMMAND [ARGS]... + + Generic composite device + +Options: + --help Show this message and exit. + +Commands: + adb Run adb using a local executable against the remote adb server. + power Generic power + scrcpy Run scrcpy using a local executable against the remote adb server. + +~/repos/jumpstarter ⚡ local ➤ exit +``` + +### `AdbClient` + +The `AdbClient` provides methods to forward the ADB server from an exporter to the client and interact with ADB either through the [`adbutils`](https://github.com/openatx/adbutils) Python package or via the `adb` CLI tool. + +### CLI + +This client provides a wrapper CLI around your local `adb` tool to provide additional +Jumpstarter functionality such as automatic port forwarding and remote control +of the ADB server on the exporter. + +```plain +~/jumpstarter ⚡local ➤ j android adb --help +Usage: j android adb [OPTIONS] [ARGS]... + + Run adb using a local adb binary against the remote adb server. + + This command is a wrapper around the adb command-line tool. It allows you to + run regular adb commands with an automatically forwarded adb server running + on your Jumpstarter exporter. + + When executing this command, the exporter adb daemon is forwarded to a local + port. The adb server address and port are automatically set in the + environment variables ANDROID_ADB_SERVER_ADDRESS and + ANDROID_ADB_SERVER_PORT, respectively. This configures your local adb client + to communicate with the remote adb server. + + Most command line arguments and commands are passed directly to the adb CLI. + However, some arguments and commands are not supported by the Jumpstarter + adb client. These options include: -a, -d, -e, -L, --one-device. + + The following adb commands are also not supported in remote adb + environments: connect, disconnect, reconnect, nodaemon, pair + + When running start-server or kill-server, Jumpstarter will start or kill the + adb server on the exporter. + + Use the forward-adb command to forward the adb server address and port to a + local port manually. + +Options: + -H TEXT Local adb host to forward to. [default: 127.0.0.1] + -P INTEGER Local adb port to forward to. [default: 5038] + --adb TEXT Path to the ADB executable [default: adb] + --help Show this message and exit. +``` + +### API Reference + +```{eval-rst} +.. autoclass:: jumpstarter_driver_android.client.AdbClient() + :members: forward_adb, adb_client +``` + +### `ScrcpyClient` + +The `ScrcpyClient` provides CLI integration with the [`scrcpy`](https://github.com/Genymobile/scrcpy) tool for remotely interacting with physical and virtual Android devices. + +> **Note:** The `scrcpy` CLI tool is required on your client device to use this driver client. + +#### CLI + +Similar to the ADB client, the `ScrcpyClient` also provides a wrapper around +the local `scrcpy` tool to automatically port-forward the ADB connection. + +```plain +~/jumpstarter ⚡local ➤ j android scrcpy --help +Usage: j android scrcpy [OPTIONS] [ARGS]... + + Run scrcpy using a local executable against the remote adb server. + + This command is a wrapper around the scrcpy command-line tool. It allows you + to run scrcpy against a remote Android device through an ADB server tunneled + via Jumpstarter. + + When executing this command, the adb server address and port are forwarded + to the local scrcpy executable. The adb server socket path is set in the + environment variable ADB_SERVER_SOCKET, allowing scrcpy to communicate with + the remote adb server. + + Most command line arguments are passed directly to the scrcpy executable. + +Options: + -H TEXT Local adb host to forward to. [default: 127.0.0.1] + -P INTEGER Local adb port to forward to. [default: 5038] + --scrcpy TEXT Path to the scrcpy executable [default: scrcpy] + --help Show this message and exit. +``` From 53cc89cb9c223b8f5e23348acf3bdcf900681f8a Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 13:19:10 -0400 Subject: [PATCH 11/16] Fix docs build --- docs/source/reference/package-apis/drivers/android.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/reference/package-apis/drivers/android.md b/docs/source/reference/package-apis/drivers/android.md index d8d677ff4..134b80122 100644 --- a/docs/source/reference/package-apis/drivers/android.md +++ b/docs/source/reference/package-apis/drivers/android.md @@ -223,7 +223,7 @@ The `AndroidClient` provides a generic composite client for interacting with And #### CLI -```plain +```bash $ jmp shell --exporter-config ~/.config/jumpstarter/exporters/android-local.yaml ~/jumpstarter ⚡ local ➤ j android @@ -252,7 +252,7 @@ This client provides a wrapper CLI around your local `adb` tool to provide addit Jumpstarter functionality such as automatic port forwarding and remote control of the ADB server on the exporter. -```plain +```bash ~/jumpstarter ⚡local ➤ j android adb --help Usage: j android adb [OPTIONS] [ARGS]... @@ -306,7 +306,7 @@ The `ScrcpyClient` provides CLI integration with the [`scrcpy`](https://github.c Similar to the ADB client, the `ScrcpyClient` also provides a wrapper around the local `scrcpy` tool to automatically port-forward the ADB connection. -```plain +```bash ~/jumpstarter ⚡local ➤ j android scrcpy --help Usage: j android scrcpy [OPTIONS] [ARGS]... From 41748acf12639ff41085db66149b15c39ffb1dd3 Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 13:26:10 -0400 Subject: [PATCH 12/16] Add Android driver to docs --- .../reference/package-apis/drivers/index.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index c1411370e..98f61f657 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -15,6 +15,8 @@ function: Drivers that control the power state and basic operation of devices: * **[Power](power.md)** (`jumpstarter-driver-power`) - Power control for devices +* **[Android](android.md)** (`jumpstarter-driver-android`) - + Android device control over ADB * **[Raspberry Pi](raspberrypi.md)** (`jumpstarter-driver-raspberrypi`) - Raspberry Pi hardware control * **[Yepkit](yepkit.md)** (`jumpstarter-driver-yepkit`) - Yepkit hardware @@ -54,6 +56,16 @@ Drivers that handle media streams: * **[UStreamer](ustreamer.md)** (`jumpstarter-driver-ustreamer`) - Video streaming functionality +### Virtualization Drivers + +Drivers for running virtual machines and systems: + +* **[QEMU](qemu.md)** (`jumpstarter-driver-qemu`) - QEMU virtualization platform +* **[Corellium](corellium.md)** (`jumpstarter-driver-corellium`) - Corellium + virtualization platform +* **[Android](android.md)** (`jumpstarter-driver-android`) - + Android Virtual Device (AVD) emulator + ### Debug and Programming Drivers Drivers for debugging and programming devices: @@ -62,9 +74,6 @@ Drivers for debugging and programming devices: programming tools * **[Probe-RS](probe-rs.md)** (`jumpstarter-driver-probe-rs`) - Debugging probe support -* **[QEMU](qemu.md)** (`jumpstarter-driver-qemu`) - QEMU virtualization platform -* **[Corellium](corellium.md)** (`jumpstarter-driver-corellium`) - Corellium - virtualization platform * **[U-Boot](uboot.md)** (`jumpstarter-driver-uboot`) - Universal Bootloader interface From f9b2e5b65b95fed91aec359312013debe09e5f02 Mon Sep 17 00:00:00 2001 From: Kirk Date: Tue, 6 May 2025 13:33:26 -0400 Subject: [PATCH 13/16] Fix toctree --- docs/source/reference/package-apis/drivers/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 98f61f657..520454294 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -85,6 +85,7 @@ General-purpose utility drivers: ```{toctree} :hidden: +android.md can.md corellium.md dutlink.md From dd2191dc94265f460f697d29492ee2ae77ecb953 Mon Sep 17 00:00:00 2001 From: Kirk Date: Sat, 31 May 2025 13:14:39 -0400 Subject: [PATCH 14/16] Fix Android emulator CLI args tests --- .../jumpstarter_driver_android/driver.py | 0 .../jumpstarter_driver_android/driver/adb.py | 10 +- .../driver/adb_test.py | 80 +++ .../driver/device.py | 15 +- .../driver/emulator.py | 227 ++++--- .../driver/emulator_test.py | 555 ++++++++++++++++++ .../driver/options.py | 104 ++-- 7 files changed, 846 insertions(+), 145 deletions(-) delete mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb_test.py create mode 100644 packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator_test.py diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py index 279a2882a..3791d5d48 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py @@ -85,17 +85,18 @@ def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - if self.port < 0 or self.port > 65535: - raise ConfigurationError(f"Invalid port number: {self.port}") if not isinstance(self.port, int): raise ConfigurationError(f"Port must be an integer: {self.port}") + if self.port < 0 or self.port > 65535: + raise ConfigurationError(f"Invalid port number: {self.port}") + self.logger.info(f"ADB server will run on port {self.port}") if self.adb_path == "adb": self.adb_path = shutil.which("adb") if not self.adb_path: - raise ConfigurationError(f"ADB executable '{self.adb_executable}' not found in PATH.") + raise ConfigurationError(f"ADB executable '{self.adb_path}' not found in PATH.") try: result = subprocess.run( @@ -104,6 +105,3 @@ def __post_init__(self): self._print_output(result.stdout, debug=True) except subprocess.CalledProcessError as e: self.logger.error(f"Failed to execute adb: {e}") - - # def close(self): - # self.kill_server() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb_test.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb_test.py new file mode 100644 index 000000000..54177ee7e --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb_test.py @@ -0,0 +1,80 @@ +import os +import subprocess +from unittest.mock import MagicMock, call, patch + +import pytest + +from jumpstarter_driver_android.driver.adb import AdbServer + +from jumpstarter.common.exceptions import ConfigurationError + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run") +def test_start_server(mock_subprocess_run: MagicMock, _: MagicMock): + mock_subprocess_run.side_effect = [ + MagicMock(stdout="ADB version", stderr="", returncode=0), + MagicMock(stdout="ADB server started", stderr="", returncode=0), + ] + + adb_server = AdbServer() + port = adb_server.start_server() + + assert port == 5037 + mock_subprocess_run.assert_has_calls( + [ + call(["/usr/bin/adb", "version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True), + call( + ["/usr/bin/adb", "start-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"ANDROID_ADB_SERVER_PORT": "5037", **dict(os.environ)}, + ), + ] + ) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run") +def test_kill_server(mock_subprocess_run: MagicMock, _: MagicMock): + mock_subprocess_run.side_effect = [ + MagicMock(stdout="ADB version", stderr="", returncode=0), + MagicMock(stdout="ADB server stopped", stderr="", returncode=0), + ] + + adb_server = AdbServer() + port = adb_server.kill_server() + + assert port == 5037 + mock_subprocess_run.assert_has_calls( + [ + call(["/usr/bin/adb", "version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True), + call( + ["/usr/bin/adb", "kill-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={"ANDROID_ADB_SERVER_PORT": "5037", **dict(os.environ)}, + ), + ] + ) + + +@patch("shutil.which", return_value=None) +def test_missing_adb_executable(_: MagicMock) -> None: + with pytest.raises(ConfigurationError): + AdbServer() + + +def test_invalid_port(): + with pytest.raises(ConfigurationError): + AdbServer(port=-1) + + with pytest.raises(ConfigurationError): + AdbServer(port=70000) + + with pytest.raises(ConfigurationError): + AdbServer(port="not_an_int") diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py index 2ca4fe694..8b22f76cd 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass +from dataclasses import field from typing import override +from pydantic.dataclasses import dataclass + from jumpstarter_driver_android.driver.adb import AdbServer from jumpstarter_driver_android.driver.options import AdbOptions from jumpstarter_driver_android.driver.scrcpy import Scrcpy @@ -19,14 +21,9 @@ class AndroidDevice(Driver): def client(cls) -> str: return "jumpstarter_driver_android.client.AndroidClient" - adb: AdbOptions - disable_scrcpy: bool = False - disable_adb: bool = False - - def __init__(self, **kwargs): - self.adb = AdbOptions.model_validate(kwargs.get("adb", {})) - if hasattr(super(), "__init__"): - super().__init__(**kwargs) + adb: AdbOptions = field(default_factory=AdbOptions) + disable_scrcpy: bool = field(default=False) + disable_adb: bool = field(default=False) def __post_init__(self): if hasattr(super(), "__post_init__"): diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py index e9f21847a..ffa692945 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -1,13 +1,13 @@ import os import subprocess import threading -from dataclasses import dataclass, field +from dataclasses import field from subprocess import TimeoutExpired -from typing import IO, AsyncGenerator, Optional +from typing import IO, AsyncGenerator -from anyio.abc import Process from jumpstarter_driver_power.common import PowerReading from jumpstarter_driver_power.driver import PowerInterface +from pydantic.dataclasses import dataclass from jumpstarter_driver_android.driver.device import AndroidDevice from jumpstarter_driver_android.driver.options import EmulatorOptions @@ -21,24 +21,27 @@ class AndroidEmulator(AndroidDevice): AndroidEmulator class provides an interface to configure and manage an Android Emulator instance. """ - emulator: EmulatorOptions - - def __init__(self, **kwargs): - self.emulator = EmulatorOptions.model_validate(kwargs.get("emulator", {})) - super().__init__(**kwargs) + emulator: EmulatorOptions = field(default_factory=EmulatorOptions) def __post_init__(self): - super().__post_init__() - self.children["power"] = AndroidEmulatorPower(parent=self, log_level=self.log_level) + if hasattr(super(), "__post_init__"): + super().__post_init__() + + # Add the android emulator power driver + self.children["power"] = AndroidEmulatorPower(parent=self) @dataclass(kw_only=True) class AndroidEmulatorPower(PowerInterface, Driver): parent: AndroidEmulator - _process: Optional[Process] = field(init=False, repr=False, compare=False, default=None) - _log_thread: Optional[threading.Thread] = field(init=False, repr=False, compare=False, default=None) - _stderr_thread: Optional[threading.Thread] = field(init=False, repr=False, compare=False, default=None) + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + self._process = None + self._log_thread = None + self._stderr_thread = None def _process_logs(self, pipe: IO[bytes], is_stderr: bool = False) -> None: """Process logs from the emulator and redirect them to the Python logger.""" @@ -88,7 +91,13 @@ def _make_emulator_command(self) -> list[str]: # Add emulator arguments from EmulatorArguments args = self.parent.emulator - # System/Core Arguments + # Core Configuration + cmdline += ["-avd-arch", args.avd_arch] if args.avd_arch else [] + cmdline += ["-id", args.id] if args.id else [] + cmdline += ["-cores", str(args.cores)] if args.cores else [] + cmdline += ["-memory", str(args.memory)] if args.memory else [] + + # System Images and Storage cmdline += ["-sysdir", args.sysdir] if args.sysdir else [] cmdline += ["-system", args.system] if args.system else [] cmdline += ["-vendor", args.vendor] if args.vendor else [] @@ -99,109 +108,162 @@ def _make_emulator_command(self) -> list[str]: cmdline += ["-cache", args.cache] if args.cache else [] cmdline += ["-cache-size", str(args.cache_size)] if args.cache_size else [] cmdline += ["-no-cache"] if args.no_cache else [] - cmdline += ["-cores", str(args.cores)] if args.cores else [] + cmdline += ["-datadir", args.datadir] if args.datadir else [] + cmdline += ["-initdata", args.initdata] if args.initdata else [] - # Boot/Snapshot Control - cmdline += ["-delay-adb"] if args.delay_adb else [] - cmdline += ["-quit-after-boot", str(args.quit_after_boot)] if args.quit_after_boot else [] + # Snapshot Management + cmdline += ["-snapstorage", args.snapstorage] if args.snapstorage else [] + cmdline += ["-no-snapstorage"] if args.no_snapstorage else [] + cmdline += ["-snapshot", args.snapshot] if args.snapshot else [] + cmdline += ["-no-snapshot"] if args.no_snapshot else [] + cmdline += ["-no-snapshot-save"] if args.no_snapshot_save else [] + cmdline += ["-no-snapshot-load"] if args.no_snapshot_load else [] cmdline += ["-force-snapshot-load"] if args.force_snapshot_load else [] cmdline += ["-no-snapshot-update-time"] if args.no_snapshot_update_time else [] + cmdline += ["-snapshot-list"] if args.snapshot_list else [] cmdline += ["-qcow2-for-userdata"] if args.qcow2_for_userdata else [] - # Network/Communication - cmdline += ["-wifi-client-port", str(args.wifi_client_port)] if args.wifi_client_port else [] - cmdline += ["-wifi-server-port", str(args.wifi_server_port)] if args.wifi_server_port else [] - cmdline += ["-net-tap", args.net_tap] if args.net_tap else [] - cmdline += ["-net-tap-script-up", args.net_tap_script_up] if args.net_tap_script_up else [] - cmdline += ["-net-tap-script-down", args.net_tap_script_down] if args.net_tap_script_down else [] - - # Display/UI + # Display and GPU + cmdline += ["-no-window"] if args.no_window else [] + cmdline += ["-gpu", args.gpu] if args.gpu else [] + cmdline += ["-no-boot-anim"] if args.no_boot_anim else [] + cmdline += ["-skin", args.skin] if args.skin else [] + cmdline += ["-skindir", args.skindir] if args.skindir else [] + cmdline += ["-no-skin"] if args.no_skin else [] cmdline += ["-dpi-device", str(args.dpi_device)] if args.dpi_device else [] cmdline += ["-fixed-scale"] if args.fixed_scale else [] + cmdline += ["-scale", args.scale] if args.scale else [] cmdline += ["-vsync-rate", str(args.vsync_rate)] if args.vsync_rate else [] + cmdline += ["-qt-hide-window"] if args.qt_hide_window else [] + for display in args.multidisplay: + cmdline += ["-multidisplay", ",".join(map(str, display))] + cmdline += ["-no-location-ui"] if args.no_location_ui else [] + cmdline += ["-no-hidpi-scaling"] if args.no_hidpi_scaling else [] + cmdline += ["-no-mouse-reposition"] if args.no_mouse_reposition else [] for name, file in args.virtualscene_poster.items(): cmdline += ["-virtualscene-poster", f"{name}={file}"] + cmdline += ["-guest-angle"] if args.guest_angle else [] + cmdline += ["-window-size", args.window_size] if args.window_size else [] + cmdline += ["-screen", args.screen] if args.screen else [] + cmdline += ["-use-host-vulkan"] if args.use_host_vulkan else [] + cmdline += ["-share-vid"] if args.share_vid else [] + cmdline += ["-hotplug-multi-display"] if args.hotplug_multi_display else [] - # Audio + # Network Configuration + cmdline += ["-wifi-client-port", str(args.wifi_client_port)] if args.wifi_client_port else [] + cmdline += ["-wifi-server-port", str(args.wifi_server_port)] if args.wifi_server_port else [] + cmdline += ["-net-tap", args.net_tap] if args.net_tap else [] + cmdline += ["-net-tap-script-up", args.net_tap_script_up] if args.net_tap_script_up else [] + cmdline += ["-net-tap-script-down", args.net_tap_script_down] if args.net_tap_script_down else [] + cmdline += ["-net-socket", args.net_socket] if args.net_socket else [] + cmdline += ["-dns-server", args.dns_server] if args.dns_server else [] + cmdline += ["-http-proxy", args.http_proxy] if args.http_proxy else [] + cmdline += ["-netdelay", args.netdelay] if args.netdelay else [] + cmdline += ["-netspeed", args.netspeed] if args.netspeed else [] + cmdline += ["-port", str(args.port)] if args.port else [] + cmdline += ["-ports", args.ports] if args.ports else [] + cmdline += ["-netfast"] if args.netfast else [] + cmdline += ["-shared-net-id", str(args.shared_net_id)] if args.shared_net_id else [] + cmdline += ["-wifi-tap", args.wifi_tap] if args.wifi_tap else [] + cmdline += ["-wifi-tap-script-up", args.wifi_tap_script_up] if args.wifi_tap_script_up else [] + cmdline += ["-wifi-tap-script-down", args.wifi_tap_script_down] if args.wifi_tap_script_down else [] + cmdline += ["-wifi-socket", args.wifi_socket] if args.wifi_socket else [] + cmdline += ["-vmnet-bridged", args.vmnet_bridged] if args.vmnet_bridged else [] + cmdline += ["-vmnet-shared"] if args.vmnet_shared else [] + cmdline += ["-vmnet-start-address", args.vmnet_start_address] if args.vmnet_start_address else [] + cmdline += ["-vmnet-end-address", args.vmnet_end_address] if args.vmnet_end_address else [] + cmdline += ["-vmnet-subnet-mask", args.vmnet_subnet_mask] if args.vmnet_subnet_mask else [] + cmdline += ["-vmnet-isolated"] if args.vmnet_isolated else [] + cmdline += ["-wifi-user-mode-options", args.wifi_user_mode_options] if args.wifi_user_mode_options else [] + cmdline += ( + ["-network-user-mode-options", args.network_user_mode_options] if args.network_user_mode_options else [] + ) + cmdline += ["-wifi-mac-address", args.wifi_mac_address] if args.wifi_mac_address else [] + cmdline += ["-no-ethernet"] if args.no_ethernet else [] + + # Audio Configuration cmdline += ["-no-audio"] if args.no_audio else [] cmdline += ["-audio", args.audio] if args.audio else [] cmdline += ["-allow-host-audio"] if args.allow_host_audio else [] + cmdline += ["-radio", args.radio] if args.radio else [] + + # Camera Configuration + cmdline += ["-camera-back", args.camera_back] if args.camera_back else [] + cmdline += ["-camera-front", args.camera_front] if args.camera_front else [] + cmdline += ["-legacy-fake-camera"] if args.legacy_fake_camera else [] + cmdline += ["-camera-hq-edge"] if args.camera_hq_edge else [] - # Locale/Language + # Localization + cmdline += ["-timezone", args.timezone] if args.timezone else [] cmdline += ["-change-language", args.change_language] if args.change_language else [] cmdline += ["-change-country", args.change_country] if args.change_country else [] cmdline += ["-change-locale", args.change_locale] if args.change_locale else [] - # Additional Display/UI options - cmdline += ["-qt-hide-window"] if args.qt_hide_window else [] - for display in args.multidisplay: - cmdline += ["-multidisplay", ",".join(map(str, display))] - cmdline += ["-no-location-ui"] if args.no_location_ui else [] - cmdline += ["-no-hidpi-scaling"] if args.no_hidpi_scaling else [] - cmdline += ["-no-mouse-reposition"] if args.no_mouse_reposition else [] + # Security + cmdline += ["-selinux", args.selinux] if args.selinux else [] + cmdline += ["-skip-adb-auth"] if args.skip_adb_auth else [] - # Additional System Control - cmdline += ["-detect-image-hang"] if args.detect_image_hang else [] - for feature, enabled in args.feature.items(): - cmdline += ["-feature", f"{feature}={'on' if enabled else 'off'}"] - cmdline += ["-icc-profile", args.icc_profile] if args.icc_profile else [] - cmdline += ["-sim-access-rules-file", args.sim_access_rules_file] if args.sim_access_rules_file else [] - cmdline += ["-phone-number", args.phone_number] if args.phone_number else [] + # Hardware Acceleration + cmdline += ["-accel", args.accel] if args.accel else [] + cmdline += ["-no-accel"] if args.no_accel else [] + cmdline += ["-engine", args.engine] if args.engine else [] + cmdline += ["-ranchu"] if args.ranchu else [] + cmdline += ["-cpu-delay", str(args.cpu_delay)] if args.cpu_delay else [] - # Additional Network/gRPC options + # Debugging and Monitoring + cmdline += ["-verbose"] if args.verbose else [] + cmdline += ["-show-kernel"] if args.show_kernel else [] + cmdline += ["-logcat", args.logcat] if args.logcat else [] + cmdline += ["-logcat-output", args.logcat_output] if args.logcat_output else [] + cmdline += ["-debug", args.debug_tags] if args.debug_tags else [] + cmdline += ["-tcpdump", args.tcpdump] if args.tcpdump else [] + cmdline += ["-detect-image-hang"] if args.detect_image_hang else [] + cmdline += ["-save-path", args.save_path] if args.save_path else [] + cmdline += ["-metrics-to-console"] if args.metrics_to_console else [] + cmdline += ["-metrics-collection"] if args.metrics_collection else [] + cmdline += ["-metrics-to-file", args.metrics_to_file] if args.metrics_to_file else [] + cmdline += ["-no-metrics"] if args.no_metrics else [] + cmdline += ["-perf-stat", args.perf_stat] if args.perf_stat else [] + cmdline += ["-no-nested-warnings"] if args.no_nested_warnings else [] + cmdline += ["-no-direct-adb"] if args.no_direct_adb else [] + cmdline += ["-check-snapshot-loadable", args.check_snapshot_loadable] if args.check_snapshot_loadable else [] + + # gRPC Configuration cmdline += ["-grpc-port", str(args.grpc_port)] if args.grpc_port else [] cmdline += ["-grpc-tls-key", args.grpc_tls_key] if args.grpc_tls_key else [] cmdline += ["-grpc-tls-cert", args.grpc_tls_cert] if args.grpc_tls_cert else [] cmdline += ["-grpc-tls-ca", args.grpc_tls_ca] if args.grpc_tls_ca else [] cmdline += ["-grpc-use-token"] if args.grpc_use_token else [] cmdline += ["-grpc-use-jwt"] if args.grpc_use_jwt else [] + cmdline += ["-grpc-allowlist", args.grpc_allowlist] if args.grpc_allowlist else [] + cmdline += ["-idle-grpc-timeout", str(args.idle_grpc_timeout)] if args.idle_grpc_timeout else [] + cmdline += ["-grpc-ui"] if args.grpc_ui else [] - # Existing arguments - cmdline += ["-no-boot-anim"] if args.no_boot_anim else [] - cmdline += ["-no-snapshot"] if args.no_snapshot else [] - cmdline += ["-no-snapshot-load"] if args.no_snapshot_load else [] - cmdline += ["-no-snapshot-save"] if args.no_snapshot_save else [] - cmdline += ["-no-window"] if args.no_window else [] - cmdline += ["-gpu", args.gpu] if args.gpu else [] - cmdline += ["-memory", str(args.memory)] if args.memory else [] - cmdline += ["-partition-size", str(args.partition_size)] if args.partition_size else [] - cmdline += ["-sdcard", args.sdcard] if args.sdcard else [] - cmdline += ["-skin", args.skin] if args.skin else [] - cmdline += ["-timezone", args.timezone] if args.timezone else [] - cmdline += ["-verbose"] if args.verbose else [] - cmdline += ["-writable-system"] if args.writable_system else [] - cmdline += ["-show-kernel"] if args.show_kernel else [] - cmdline += ["-logcat", args.logcat] if args.logcat else [] - cmdline += ["-camera-back", args.camera_back] if args.camera_back else [] - cmdline += ["-camera-front", args.camera_front] if args.camera_front else [] - cmdline += ["-selinux", args.selinux] if args.selinux else [] - cmdline += ["-dns-server", args.dns_server] if args.dns_server else [] - cmdline += ["-http-proxy", args.http_proxy] if args.http_proxy else [] - cmdline += ["-netdelay", args.netdelay] if args.netdelay else [] - cmdline += ["-netspeed", args.netspeed] if args.netspeed else [] - cmdline += ["-port", str(args.port)] if args.port else [] - cmdline += ["-tcpdump", args.tcpdump] if args.tcpdump else [] - cmdline += ["-accel", args.accel] if args.accel else [] - cmdline += ["-engine", args.engine] if args.engine else [] - cmdline += ["-no-accel"] if args.no_accel else [] - cmdline += ["-gpu", args.gpu_mode] if args.gpu_mode else [] - cmdline += ["-wipe-data"] if args.wipe_data else [] - cmdline += ["-debug", args.debug_tags] if args.debug_tags else [] - - # Advanced System Arguments + # Advanced System Configuration cmdline += ["-acpi-config", args.acpi_config] if args.acpi_config else [] for key, value in args.append_userspace_opt.items(): cmdline += ["-append-userspace-opt", f"{key}={value}"] - cmdline += ["-guest-angle"] if args.guest_angle else [] + for feature, enabled in args.feature.items(): + cmdline += ["-feature", f"{feature}={'on' if enabled else 'off'}"] + cmdline += ["-icc-profile", args.icc_profile] if args.icc_profile else [] + cmdline += ["-sim-access-rules-file", args.sim_access_rules_file] if args.sim_access_rules_file else [] + cmdline += ["-phone-number", args.phone_number] if args.phone_number else [] if args.usb_passthrough: cmdline += ["-usb-passthrough"] + list(map(str, args.usb_passthrough)) - cmdline += ["-save-path", args.save_path] if args.save_path else [] cmdline += ["-waterfall", args.waterfall] if args.waterfall else [] cmdline += ["-restart-when-stalled"] if args.restart_when_stalled else [] + cmdline += ["-wipe-data"] if args.wipe_data else [] + cmdline += ["-delay-adb"] if args.delay_adb else [] + cmdline += ["-quit-after-boot", str(args.quit_after_boot)] if args.quit_after_boot else [] + cmdline += ["-android-serialno", args.android_serialno] if args.android_serialno else [] + cmdline += ["-systemui-renderer", args.systemui_renderer] if args.systemui_renderer else [] - # Add any remaining QEMU arguments at the end + # QEMU Configuration if args.qemu_args: cmdline += ["-qemu"] + args.qemu_args + for key, value in args.props.items(): + cmdline += ["-prop", f"{key}={value}"] + cmdline += ["-adb-path", args.adb_path] if args.adb_path else [] return cmdline @@ -222,10 +284,6 @@ def on(self) -> None: env["ANDROID_ADB_SERVER_PORT"] = str(self.parent.adb.port) env["ANDROID_ADB_SERVER_ADDRESS"] = self.parent.adb.host - self.logger.info("Starting with environment variables:") - for key, value in env.items(): - self.logger.info(f"{key}: {value}") - self.logger.info(f"Starting Android emulator with command: {' '.join(cmdline)}") self._process = subprocess.Popen( cmdline, @@ -301,7 +359,8 @@ def off(self) -> None: # noqa: C901 @export async def read(self) -> AsyncGenerator[PowerReading, None]: - pass + yield PowerReading(voltage=0.0, current=0.0) + return def close(self): self.off() diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator_test.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator_test.py new file mode 100644 index 000000000..29cde1af7 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator_test.py @@ -0,0 +1,555 @@ +import os +import subprocess +from subprocess import TimeoutExpired +from unittest.mock import MagicMock, call, patch + +import pytest + +from jumpstarter_driver_android.driver.emulator import AndroidEmulator, AndroidEmulatorPower +from jumpstarter_driver_android.driver.options import AdbOptions, EmulatorOptions + + +@pytest.fixture +# Need to patch the imports in the AndroidDevice class +@patch("jumpstarter_driver_android.driver.device.AdbServer") +@patch("jumpstarter_driver_android.driver.device.Scrcpy") +def android_emulator(scrcpy: MagicMock, adb: MagicMock): + adb.return_value = MagicMock() + scrcpy.return_value = MagicMock() + emulator = AndroidEmulator( + emulator=EmulatorOptions(emulator_path="/path/to/emulator", avd="test_avd", port=5554), + adb=AdbOptions(adb_path="/path/to/adb", port=5037), + ) + return emulator + + +@pytest.fixture +def emulator_power(android_emulator: AndroidEmulator): + return AndroidEmulatorPower(parent=android_emulator) + + +@patch("subprocess.Popen") +@patch("threading.Thread") +def test_emulator_on(_: MagicMock, mock_popen: MagicMock, emulator_power: AndroidEmulatorPower): + mock_process = MagicMock() + mock_popen.return_value = mock_process + + emulator_power.on() + + expected_calls = [ + call( + [ + "/path/to/emulator", + "-avd", + "test_avd", + "-cores", + "4", + "-memory", + "2048", + "-no-window", + "-gpu", + "auto", + "-scale", + "1", + "-netdelay", + "none", + "-netspeed", + "full", + "-port", + "5554", + "-camera-back", + "emulated", + "-camera-front", + "emulated", + "-accel", + "auto", + "-engine", + "auto", + "-grpc-use-jwt", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=False, + env={ + **dict(os.environ), + **emulator_power.parent.emulator.env, + "ANDROID_ADB_SERVER_PORT": "5037", + "ANDROID_ADB_SERVER_ADDRESS": "127.0.0.1", + }, + ) + ] + + mock_popen.assert_has_calls(expected_calls, any_order=True) + + +@patch("subprocess.run") +def test_emulator_off_adb_kill(mock_run: MagicMock, emulator_power: AndroidEmulatorPower): + mock_process = MagicMock() + mock_process.returncode = None + emulator_power._process = mock_process + mock_run.return_value = MagicMock(stdout="Emulator killed", stderr="", returncode=0) + + emulator_power.off() + + # Assert that ADB kill is executed + mock_run.assert_called_once_with( + [ + "/path/to/adb", + "-s", + "emulator-5554", + "emu", + "kill", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={ + "ANDROID_ADB_SERVER_PORT": "5037", + **dict(os.environ), + }, + ) + + # Verify that the process wait was called + mock_process.wait.assert_called_once_with(timeout=20) + mock_process.kill.assert_not_called() + + # Verify that the process and threads are cleaned up + assert emulator_power._process is None + assert emulator_power._log_thread is None + assert emulator_power._stderr_thread is None + + +@patch("subprocess.run") +def test_emulator_off_timeout(mock_run: MagicMock, emulator_power: AndroidEmulatorPower): + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = MagicMock( + side_effect=TimeoutExpired(cmd="/path/to/adb -s emulator-5554 emu kill", timeout=20) + ) # Simulate timeout + mock_process.kill = MagicMock() # Simulate process kill + emulator_power._process = mock_process + + emulator_power.off() + + # Verify that the process wait was called + mock_process.wait.assert_called_once_with(timeout=20) + + # Verify that the process kill was called after timeout + mock_process.kill.assert_called_once() + + # Verify that the process and threads are cleaned up + assert emulator_power._process is None + assert emulator_power._log_thread is None + assert emulator_power._stderr_thread is None + + +@patch("subprocess.Popen") +@patch("threading.Thread") +@patch("jumpstarter_driver_android.driver.device.AdbServer") +@patch("jumpstarter_driver_android.driver.device.Scrcpy") +def test_emulator_arguments(scrcpy: MagicMock, adb: MagicMock, mock_thread: MagicMock, mock_popen: MagicMock): + adb.return_value = MagicMock() + scrcpy.return_value = MagicMock() + mock_process = MagicMock() + mock_popen.return_value = mock_process + + emulator_options = EmulatorOptions( + emulator_path="/path/to/emulator", + avd="test_avd", + sysdir="/path/to/sysdir", + system="/path/to/system.img", + vendor="/path/to/vendor.img", + kernel="/path/to/kernel", + ramdisk="/path/to/ramdisk.img", + data="/path/to/userdata.img", + sdcard="/path/to/sdcard.img", + snapshot="/path/to/snapshot.img", + avd_arch="x86_64", + id="test_id", + cores=4, + memory=2048, + encryption_key="/path/to/key", + cache="/path/to/cache", + cache_size=1024, + no_cache=True, + datadir="/path/to/data", + initdata="/path/to/initdata", + snapstorage="/path/to/snapstorage", + no_snapstorage=True, + no_snapshot=True, + no_snapshot_save=True, + no_snapshot_load=True, + force_snapshot_load=True, + no_snapshot_update_time=True, + snapshot_list=True, + qcow2_for_userdata=True, + no_window=True, + gpu="host", + no_boot_anim=True, + skin="pixel_2", + skindir="/path/to/skins", + no_skin=True, + dpi_device=420, + fixed_scale=True, + scale="1.0", + vsync_rate=60, + qt_hide_window=True, + multidisplay=[(0, 0, 1080, 1920, 0)], + no_location_ui=True, + no_hidpi_scaling=True, + no_mouse_reposition=True, + virtualscene_poster={"name": "/path/to/poster.jpg"}, + guest_angle=True, + window_size="1080x1920", + screen="touch", + use_host_vulkan=True, + share_vid=True, + hotplug_multi_display=True, + wifi_client_port=5555, + wifi_server_port=5556, + net_tap="tap0", + net_tap_script_up="/path/to/up.sh", + net_tap_script_down="/path/to/down.sh", + net_socket="socket0", + dns_server="8.8.8.8", + http_proxy="http://proxy:8080", + netdelay="none", + netspeed="full", + port=5554, + ports="5554,5555", + netfast=True, + shared_net_id=1, + wifi_tap="wifi0", + wifi_tap_script_up="/path/to/wifi_up.sh", + wifi_tap_script_down="/path/to/wifi_down.sh", + wifi_socket="wifi_socket", + vmnet_bridged="en0", + vmnet_shared=True, + vmnet_start_address="192.168.1.1", + vmnet_end_address="192.168.1.254", + vmnet_subnet_mask="255.255.255.0", + vmnet_isolated=True, + wifi_user_mode_options="option1=value1", + network_user_mode_options="option2=value2", + wifi_mac_address="00:11:22:33:44:55", + no_ethernet=True, + no_audio=True, + audio="host", + allow_host_audio=True, + radio="modem", + camera_back="webcam0", + camera_front="emulated", + legacy_fake_camera=True, + camera_hq_edge=True, + timezone="America/New_York", + change_language="en", + change_country="US", + change_locale="en_US", + selinux="permissive", + skip_adb_auth=True, + accel="auto", + no_accel=True, + engine="auto", + ranchu=True, + cpu_delay=100, + verbose=True, + show_kernel=True, + logcat="*:V", + logcat_output="/path/to/logcat.txt", + debug_tags="all", + tcpdump="/path/to/capture.pcap", + detect_image_hang=True, + save_path="/path/to/save", + metrics_to_console=True, + metrics_collection=True, + metrics_to_file="/path/to/metrics.txt", + no_metrics=True, + perf_stat="cpu", + no_nested_warnings=True, + no_direct_adb=True, + check_snapshot_loadable="/path/to/snapshot", + grpc_port=8554, + grpc_tls_key="/path/to/key.pem", + grpc_tls_cert="/path/to/cert.pem", + grpc_tls_ca="/path/to/ca.pem", + grpc_use_token=True, + grpc_use_jwt=True, + grpc_allowlist="allowlist.txt", + idle_grpc_timeout=60, + grpc_ui=True, + acpi_config="/path/to/acpi.ini", + append_userspace_opt={"opt1": "value1"}, + feature={"feature1": True}, + icc_profile="/path/to/icc.profile", + sim_access_rules_file="/path/to/sim.rules", + phone_number="+1234567890", + usb_passthrough=[1, 2, 3, 4], + waterfall="/path/to/waterfall", + restart_when_stalled=True, + wipe_data=True, + delay_adb=True, + quit_after_boot=30, + android_serialno="emulator-5554", + systemui_renderer="skia", + qemu_args=["-enable-kvm"], + props={"prop1": "value1"}, + adb_path="/path/to/adb", + ) + emulator = AndroidEmulator(emulator=emulator_options) + + # Call the on method to trigger the command construction + emulator.children["power"].on() # type: ignore + + # Verify the command line arguments + expected_args = [ + "/path/to/emulator", + "-avd", + "test_avd", + "-avd-arch", + "x86_64", + "-id", + "test_id", + "-cores", + "4", + "-memory", + "2048", + "-sysdir", + "/path/to/sysdir", + "-system", + "/path/to/system.img", + "-vendor", + "/path/to/vendor.img", + "-kernel", + "/path/to/kernel", + "-ramdisk", + "/path/to/ramdisk.img", + "-data", + "/path/to/userdata.img", + "-encryption-key", + "/path/to/key", + "-cache", + "/path/to/cache", + "-cache-size", + "1024", + "-no-cache", + "-datadir", + "/path/to/data", + "-initdata", + "/path/to/initdata", + "-snapstorage", + "/path/to/snapstorage", + "-no-snapstorage", + "-snapshot", + "/path/to/snapshot.img", + "-no-snapshot", + "-no-snapshot-save", + "-no-snapshot-load", + "-force-snapshot-load", + "-no-snapshot-update-time", + "-snapshot-list", + "-qcow2-for-userdata", + "-no-window", + "-gpu", + "host", + "-no-boot-anim", + "-skin", + "pixel_2", + "-skindir", + "/path/to/skins", + "-no-skin", + "-dpi-device", + "420", + "-fixed-scale", + "-scale", + "1.0", + "-vsync-rate", + "60", + "-qt-hide-window", + "-multidisplay", + "0,0,1080,1920,0", + "-no-location-ui", + "-no-hidpi-scaling", + "-no-mouse-reposition", + "-virtualscene-poster", + "name=/path/to/poster.jpg", + "-guest-angle", + "-window-size", + "1080x1920", + "-screen", + "touch", + "-use-host-vulkan", + "-share-vid", + "-hotplug-multi-display", + "-wifi-client-port", + "5555", + "-wifi-server-port", + "5556", + "-net-tap", + "tap0", + "-net-tap-script-up", + "/path/to/up.sh", + "-net-tap-script-down", + "/path/to/down.sh", + "-net-socket", + "socket0", + "-dns-server", + "8.8.8.8", + "-http-proxy", + "http://proxy:8080", + "-netdelay", + "none", + "-netspeed", + "full", + "-port", + "5554", + "-ports", + "5554,5555", + "-netfast", + "-shared-net-id", + "1", + "-wifi-tap", + "wifi0", + "-wifi-tap-script-up", + "/path/to/wifi_up.sh", + "-wifi-tap-script-down", + "/path/to/wifi_down.sh", + "-wifi-socket", + "wifi_socket", + "-vmnet-bridged", + "en0", + "-vmnet-shared", + "-vmnet-start-address", + "192.168.1.1", + "-vmnet-end-address", + "192.168.1.254", + "-vmnet-subnet-mask", + "255.255.255.0", + "-vmnet-isolated", + "-wifi-user-mode-options", + "option1=value1", + "-network-user-mode-options", + "option2=value2", + "-wifi-mac-address", + "00:11:22:33:44:55", + "-no-ethernet", + "-no-audio", + "-audio", + "host", + "-allow-host-audio", + "-radio", + "modem", + "-camera-back", + "webcam0", + "-camera-front", + "emulated", + "-legacy-fake-camera", + "-camera-hq-edge", + "-timezone", + "America/New_York", + "-change-language", + "en", + "-change-country", + "US", + "-change-locale", + "en_US", + "-selinux", + "permissive", + "-skip-adb-auth", + "-accel", + "auto", + "-no-accel", + "-engine", + "auto", + "-ranchu", + "-cpu-delay", + "100", + "-verbose", + "-show-kernel", + "-logcat", + "*:V", + "-logcat-output", + "/path/to/logcat.txt", + "-debug", + "all", + "-tcpdump", + "/path/to/capture.pcap", + "-detect-image-hang", + "-save-path", + "/path/to/save", + "-metrics-to-console", + "-metrics-collection", + "-metrics-to-file", + "/path/to/metrics.txt", + "-no-metrics", + "-perf-stat", + "cpu", + "-no-nested-warnings", + "-no-direct-adb", + "-check-snapshot-loadable", + "/path/to/snapshot", + "-grpc-port", + "8554", + "-grpc-tls-key", + "/path/to/key.pem", + "-grpc-tls-cert", + "/path/to/cert.pem", + "-grpc-tls-ca", + "/path/to/ca.pem", + "-grpc-use-token", + "-grpc-use-jwt", + "-grpc-allowlist", + "allowlist.txt", + "-idle-grpc-timeout", + "60", + "-grpc-ui", + "-acpi-config", + "/path/to/acpi.ini", + "-append-userspace-opt", + "opt1=value1", + "-feature", + "feature1=on", + "-icc-profile", + "/path/to/icc.profile", + "-sim-access-rules-file", + "/path/to/sim.rules", + "-phone-number", + "+1234567890", + "-usb-passthrough", + "1", + "2", + "3", + "4", + "-waterfall", + "/path/to/waterfall", + "-restart-when-stalled", + "-wipe-data", + "-delay-adb", + "-quit-after-boot", + "30", + "-android-serialno", + "emulator-5554", + "-systemui-renderer", + "skia", + "-qemu", + "-enable-kvm", + "-prop", + "prop1=value1", + "-adb-path", + "/path/to/adb", + ] + + mock_popen.assert_called_with( + expected_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=False, + env={ + **dict(os.environ), + **emulator_options.env, + "ANDROID_ADB_SERVER_PORT": "5037", + "ANDROID_ADB_SERVER_ADDRESS": "127.0.0.1", + }, + ) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py index eba8492e6..56b6ae9b0 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py @@ -1,7 +1,6 @@ -import os from typing import Dict, List, Literal, Optional, Tuple -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field class AdbOptions(BaseModel): @@ -26,8 +25,10 @@ class EmulatorOptions(BaseModel): # Core Configuration emulator_path: str = Field(default="emulator") avd: str = Field(default="default") + avd_arch: Optional[str] = None cores: Optional[int] = Field(default=4, ge=1) memory: int = Field(default=2048, ge=1024, le=16384) + id: Optional[str] = None # System Images and Storage sysdir: Optional[str] = None @@ -39,6 +40,9 @@ class EmulatorOptions(BaseModel): sdcard: Optional[str] = None partition_size: int = Field(default=2048, ge=512, le=16384) writable_system: bool = False + datadir: Optional[str] = None + image: Optional[str] = None # obsolete, use system instead + initdata: Optional[str] = None # Cache Configuration cache: Optional[str] = None @@ -53,16 +57,20 @@ class EmulatorOptions(BaseModel): force_snapshot_load: bool = False no_snapshot_update_time: bool = False qcow2_for_userdata: bool = False + snapstorage: Optional[str] = None + no_snapstorage: bool = False + snapshot_list: bool = False # Display and GPU no_window: bool = True gpu: Literal["auto", "host", "swiftshader", "angle", "guest"] = "auto" - gpu_mode: Literal["auto", "host", "swiftshader", "angle", "guest"] = "auto" no_boot_anim: bool = False skin: Optional[str] = None + skindir: Optional[str] = None + no_skin: bool = False dpi_device: Optional[int] = Field(default=None, ge=0) fixed_scale: bool = False - scale: str = "1" + scale: str = Field(default="1", pattern=r"^[0-9]+(\.[0-9]+)?$") vsync_rate: Optional[int] = Field(default=None, ge=1) qt_hide_window: bool = False multidisplay: List[Tuple[int, int, int, int, int]] = [] @@ -71,6 +79,11 @@ class EmulatorOptions(BaseModel): no_mouse_reposition: bool = False virtualscene_poster: Dict[str, str] = {} guest_angle: bool = False + window_size: Optional[str] = Field(default=None, pattern=r"^\d+x\d+$") + screen: Optional[str] = None + use_host_vulkan: bool = False + share_vid: bool = False + hotplug_multi_display: bool = False # Network Configuration wifi_client_port: Optional[int] = Field(default=None, ge=1, le=65535) @@ -78,20 +91,41 @@ class EmulatorOptions(BaseModel): net_tap: Optional[str] = None net_tap_script_up: Optional[str] = None net_tap_script_down: Optional[str] = None + net_socket: Optional[str] = None dns_server: Optional[str] = None http_proxy: Optional[str] = None netdelay: Literal["none", "umts", "gprs", "edge", "hscsd"] = "none" netspeed: Literal["full", "gsm", "hscsd", "gprs", "edge", "umts"] = "full" port: int = Field(default=5554, ge=5554, le=5682) + ports: Optional[str] = None + netfast: bool = False + shared_net_id: Optional[int] = None + wifi_tap: Optional[str] = None + wifi_tap_script_up: Optional[str] = None + wifi_tap_script_down: Optional[str] = None + wifi_socket: Optional[str] = None + vmnet_bridged: Optional[str] = None + vmnet_shared: bool = False + vmnet_start_address: Optional[str] = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + vmnet_end_address: Optional[str] = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + vmnet_subnet_mask: Optional[str] = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + vmnet_isolated: bool = False + wifi_user_mode_options: Optional[str] = None + network_user_mode_options: Optional[str] = None + wifi_mac_address: Optional[str] = Field(default=None, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + no_ethernet: bool = False # Audio Configuration no_audio: bool = False audio: Optional[str] = None allow_host_audio: bool = False + radio: Optional[str] = None # Camera Configuration camera_back: Literal["emulated", "webcam0", "none"] = "emulated" camera_front: Literal["emulated", "webcam0", "none"] = "emulated" + legacy_fake_camera: bool = False + camera_hq_edge: bool = False # Localization timezone: Optional[str] = None @@ -102,20 +136,32 @@ class EmulatorOptions(BaseModel): # Security encryption_key: Optional[str] = None selinux: Optional[Literal["enforcing", "permissive", "disabled"]] = None + skip_adb_auth: bool = False # Hardware Acceleration accel: Literal["auto", "off", "on"] = "auto" no_accel: bool = False engine: Literal["auto", "qemu", "swiftshader"] = "auto" + ranchu: bool = False + cpu_delay: Optional[int] = None # Debugging and Monitoring verbose: bool = False show_kernel: bool = False logcat: Optional[str] = None + logcat_output: Optional[str] = None debug_tags: Optional[str] = None tcpdump: Optional[str] = None detect_image_hang: bool = False save_path: Optional[str] = None + metrics_to_console: bool = False + metrics_collection: bool = False + metrics_to_file: Optional[str] = None + no_metrics: bool = False + perf_stat: Optional[str] = None + no_nested_warnings: bool = False + no_direct_adb: bool = False + check_snapshot_loadable: Optional[str] = None # gRPC Configuration grpc_port: Optional[int] = Field(default=None, ge=1, le=65535) @@ -124,6 +170,9 @@ class EmulatorOptions(BaseModel): grpc_tls_ca: Optional[str] = None grpc_use_token: bool = False grpc_use_jwt: bool = True + grpc_allowlist: Optional[str] = None + idle_grpc_timeout: Optional[int] = None + grpc_ui: bool = False # Advanced System Configuration acpi_config: Optional[str] = None @@ -131,60 +180,23 @@ class EmulatorOptions(BaseModel): feature: Dict[str, bool] = {} icc_profile: Optional[str] = None sim_access_rules_file: Optional[str] = None - phone_number: Optional[str] = None - usb_passthrough: Optional[Tuple[int, int, int, int]] = None + phone_number: Optional[str] = Field(default=None, pattern=r"^\+[0-9]{10,15}$") + usb_passthrough: Optional[List[int]] = None waterfall: Optional[str] = None restart_when_stalled: bool = False wipe_data: bool = False delay_adb: bool = False quit_after_boot: Optional[int] = Field(default=None, ge=0) + android_serialno: Optional[str] = None + systemui_renderer: Optional[str] = None # QEMU Configuration qemu_args: List[str] = [] props: Dict[str, str] = {} + adb_path: Optional[str] = None # Additional environment variables env: Dict[str, str] = {} - @model_validator(mode="after") - def validate_paths(self) -> "EmulatorOptions": - path_fields = [ - "sysdir", - "system", - "vendor", - "kernel", - "ramdisk", - "data", - "encryption_key", - "cache", - "net_tap_script_up", - "net_tap_script_down", - "icc_profile", - "sim_access_rules_file", - "grpc_tls_key", - "grpc_tls_cert", - "grpc_tls_ca", - "acpi_config", - "save_path", - ] - - for name in path_fields: - path = getattr(self, name) - if path and not os.path.exists(path): - raise ValueError(f"Path does not exist: {path}") - - # Validate virtual scene poster paths - for _, path in self.virtualscene_poster.items(): - if not os.path.exists(path): - raise ValueError(f"Virtual scene poster image not found: {path}") - if not path.lower().endswith((".png", ".jpg", ".jpeg")): - raise ValueError(f"Virtual scene poster must be a PNG or JPEG file: {path}") - - # Validate phone number format if provided - if self.phone_number is not None and not self.phone_number.replace("+", "").replace("-", "").isdigit(): - raise ValueError("Phone number must contain only digits, '+', or '-'") - - return self - class Config: validate_assignment = True From 9688d2664a35e714ff1e1c2b15c6ce5cf92582fb Mon Sep 17 00:00:00 2001 From: Kirk Date: Sat, 31 May 2025 13:19:42 -0400 Subject: [PATCH 15/16] Fix uv.lock and remove asyncclick --- .../jumpstarter_driver_android/client.py | 2 +- .../jumpstarter-driver-android/pyproject.toml | 1 - uv.lock | 37 ++++++------------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py index c5ca17f86..70721934b 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -8,7 +8,7 @@ from typing import Generator import adbutils -import asyncclick as click +import click from jumpstarter_driver_composite.client import CompositeClient from jumpstarter_driver_network.adapters import TcpPortforwardAdapter diff --git a/packages/jumpstarter-driver-android/pyproject.toml b/packages/jumpstarter-driver-android/pyproject.toml index 223c11105..2d5ce824b 100644 --- a/packages/jumpstarter-driver-android/pyproject.toml +++ b/packages/jumpstarter-driver-android/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "jumpstarter-driver-composite", "jumpstarter-driver-network", "jumpstarter-driver-power", - "asyncclick>=8.1.7.2", "adbutils>=2.8.7", ] diff --git a/uv.lock b/uv.lock index d6585fdb2..0ef492412 100644 --- a/uv.lock +++ b/uv.lock @@ -66,20 +66,20 @@ docs = [ [[package]] name = "adbutils" -version = "2.8.7" +version = "2.8.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "pillow" }, { name = "requests" }, - { name = "retry" }, + { name = "retry2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b8/d90093a3bc54c9d61195500213d929c92bde085579cf54f2eb97a25400c0/adbutils-2.8.7.tar.gz", hash = "sha256:8e3489d4a8369500951f08cfe6dbfde1ddbdf86b9aaa96641f822181195fa0cf", size = 185622 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/24/c37bee0adc71f7b2b0b0795be4425a547598c5d28651f803e648dcf2b8ca/adbutils-2.8.11.tar.gz", hash = "sha256:d0f96e5d01e104fb42af6fa1263ff6ab9d2fc719538a11c18b4219a95c0bacc3", size = 187055, upload-time = "2025-05-28T10:03:53.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/02/bc01843446aef003e01642292bc31f728b7528cb999488a107d609c3ab9b/adbutils-2.8.7-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:443ffbb0f72532d6cfa88f5df1735b401bcd73265fab6430b3d8fc434f445d30", size = 6705052 }, - { url = "https://files.pythonhosted.org/packages/53/c8/7d65da1a2de875ee6aa44e3e743e3d586c0bd4fb415a2ae7a5a9ce5d524d/adbutils-2.8.7-py3-none-manylinux1_x86_64.whl", hash = "sha256:b44d521f9ef8453738f1d8abac84e86242715a7a6446f23e36536a6efea37c41", size = 3576001 }, - { url = "https://files.pythonhosted.org/packages/b1/98/75b6f6f85a81ed9e0de79391642ddf367f6442d764fe7b24dd7d8fb8c891/adbutils-2.8.7-py3-none-win32.whl", hash = "sha256:258686a43d5fc7820ffa72416bd57883b5fdf304f231718ed8f134105475b89f", size = 3336619 }, - { url = "https://files.pythonhosted.org/packages/ae/e9/c181fc4bfd220a89bc0fe0d1f32e86c1797710d2a76c64bb2522cfe76b68/adbutils-2.8.7-py3-none-win_amd64.whl", hash = "sha256:e74dbf9dc6b83e75dec9f6758a880557c263fc7ee4bff7d09ee736aab7b7b762", size = 3336623 }, + { url = "https://files.pythonhosted.org/packages/59/0f/34132e695fd927498367298e2cd162da8fb5ac99164545e9dd67954d3cc9/adbutils-2.8.11-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:94310cd9246bad1fba44fc5c5834fc43a1c821e966fb22f529aaefe534d353df", size = 6705906, upload-time = "2025-05-28T10:03:46.697Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e2/e8059c06f53cf8893d5f5b99f83b0d668ff9bdf04b91249a1d62dea184d2/adbutils-2.8.11-py3-none-manylinux1_x86_64.whl", hash = "sha256:752b2c12be979135cf280c15977dd46a8b388ad9c4a54fd3e9ca84e40b9c0a27", size = 3576855, upload-time = "2025-05-28T10:03:48.549Z" }, + { url = "https://files.pythonhosted.org/packages/9f/43/95ff5d4818a4ebb5eee36157dcb046f2251c7497d8dc2e019e4640209cf4/adbutils-2.8.11-py3-none-win32.whl", hash = "sha256:bf49a96da06fba5d6d3bcdeed62a188bf10c9f6dfc4c880cde4ed25a1b67e295", size = 3337476, upload-time = "2025-05-28T10:03:50.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2f/09090fb2ccd8970666ebc42c505de0a03b787e43af4325f3450aa3c538bf/adbutils-2.8.11-py3-none-win_amd64.whl", hash = "sha256:3029635959695dd677504a9ef6627ee44eb8a6ebee6aad7f55ac3113919b3d01", size = 3337478, upload-time = "2025-05-28T10:03:51.75Z" }, ] [[package]] @@ -604,9 +604,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] [[package]] @@ -1286,7 +1286,6 @@ name = "jumpstarter-driver-android" source = { editable = "packages/jumpstarter-driver-android" } dependencies = [ { name = "adbutils" }, - { name = "asyncclick" }, { name = "jumpstarter" }, { name = "jumpstarter-driver-composite" }, { name = "jumpstarter-driver-network" }, @@ -1303,7 +1302,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "adbutils", specifier = ">=2.8.7" }, - { name = "asyncclick", specifier = ">=8.1.7.2" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, { name = "jumpstarter-driver-network", editable = "packages/jumpstarter-driver-network" }, @@ -2845,15 +2843,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] -[[package]] -name = "py" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708 }, -] - [[package]] name = "pyasn1" version = "0.6.1" @@ -3360,16 +3349,14 @@ wheels = [ ] [[package]] -name = "retry" -version = "0.9.2" +name = "retry2" +version = "0.9.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "decorator" }, - { name = "py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/72/75d0b85443fbc8d9f38d08d2b1b67cc184ce35280e4a3813cda2f445f3a4/retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4", size = 6448 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/0d/53aea75710af4528a25ed6837d71d117602b01946b307a3912cb3cfcbcba/retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606", size = 7986 }, + { url = "https://files.pythonhosted.org/packages/97/49/1cae6d9b932378cc75f902fa70648945b7ea7190cb0d09ff83b47de3e60a/retry2-0.9.5-py2.py3-none-any.whl", hash = "sha256:f7fee13b1e15d0611c462910a6aa72a8919823988dd0412152bc3719c89a4e55", size = 6013, upload-time = "2023-01-11T21:49:08.397Z" }, ] [[package]] From 3e90beb2d2535df072c2f75914b49aa6078ae3f2 Mon Sep 17 00:00:00 2001 From: Kirk Date: Sat, 31 May 2025 13:24:01 -0400 Subject: [PATCH 16/16] Remove unsupported Python types --- .../jumpstarter_driver_android/driver/adb.py | 2 -- .../jumpstarter_driver_android/driver/device.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py index 3791d5d48..6805035e2 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py @@ -2,7 +2,6 @@ import shutil import subprocess from dataclasses import dataclass -from typing import override from jumpstarter_driver_network.driver import TcpNetwork @@ -17,7 +16,6 @@ class AdbServer(TcpNetwork): port: int = 5037 @classmethod - @override def client(cls) -> str: return "jumpstarter_driver_android.client.AdbClient" diff --git a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py index 8b22f76cd..3ffeb4c40 100644 --- a/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py @@ -1,5 +1,4 @@ from dataclasses import field -from typing import override from pydantic.dataclasses import dataclass @@ -17,7 +16,6 @@ class AndroidDevice(Driver): """ @classmethod - @override def client(cls) -> str: return "jumpstarter_driver_android.client.AndroidClient"