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..134b80122 --- /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 + +```bash +$ 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. + +```bash +~/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. + +```bash +~/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/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 1e5aebb7b..d49ce9f03 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 @@ -56,6 +58,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: @@ -64,9 +76,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 @@ -78,6 +87,7 @@ General-purpose utility drivers: ```{toctree} :hidden: +android.md can.md corellium.md dutlink.md diff --git a/packages/jumpstarter-all/pyproject.toml b/packages/jumpstarter-all/pyproject.toml index e282f05a3..1f30ce834 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..d8d677ff4 --- /dev/null +++ b/packages/jumpstarter-driver-android/README.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. +``` 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..70721934b --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/client.py @@ -0,0 +1,235 @@ +import errno +import os +import socket +import subprocess +import sys +from contextlib import contextmanager +from threading import Event +from typing import Generator + +import adbutils +import click +from jumpstarter_driver_composite.client import CompositeClient +from jumpstarter_driver_network.adapters import TcpPortforwardAdapter + +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 + 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. + 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 = 5038) -> 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 + + +class AdbClient(AdbClientBase): + """ADB client for interacting with Android 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.") + @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) + @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", + ) + @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 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. + """ + # 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 = [ + "connect", + "disconnect", + "reconnect", + "nodaemon", + "pair", + ] + for arg in args: + if arg in unsupported_commands: + 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"Remote adb server started on remote port exporter:{remote_port}") + return 0 + elif "kill-server" in args: + remote_port = int(self.call("kill_server")) + 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: + env = os.environ | { + "ANDROID_ADB_SERVER_ADDRESS": addr[0], + "ANDROID_ADB_SERVER_PORT": str(addr[1]), + } + cmd = [adb, *args] + process = subprocess.Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env) + return process.wait() + + return adb + + +class ScrcpyClient(AdbClientBase): + """Scrcpy client for controlling Android devices remotely.""" + + 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 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..d03f8f062 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/__init__.py @@ -0,0 +1,13 @@ +from .adb import AdbServer +from .emulator import AndroidEmulator, AndroidEmulatorPower +from .options import AdbOptions, EmulatorOptions +from .scrcpy import Scrcpy + +__all__ = [ + "AdbServer", + "AndroidEmulator", + "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 new file mode 100644 index 000000000..6805035e2 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/adb.py @@ -0,0 +1,105 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass + +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 + 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 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_path}' 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}") 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 new file mode 100644 index 000000000..3ffeb4c40 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/device.py @@ -0,0 +1,35 @@ +from dataclasses import field + +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 + +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 + def client(cls) -> str: + return "jumpstarter_driver_android.client.AndroidClient" + + 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__"): + 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 new file mode 100644 index 000000000..ffa692945 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/emulator.py @@ -0,0 +1,366 @@ +import os +import subprocess +import threading +from dataclasses import field +from subprocess import TimeoutExpired +from typing import IO, AsyncGenerator + +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 + +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class AndroidEmulator(AndroidDevice): + """ + AndroidEmulator class provides an interface to configure and manage an Android Emulator instance. + """ + + emulator: EmulatorOptions = field(default_factory=EmulatorOptions) + + def __post_init__(self): + 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 + + 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.""" + 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 + + # 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 [] + 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 += ["-datadir", args.datadir] if args.datadir else [] + cmdline += ["-initdata", args.initdata] if args.initdata 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 [] + + # 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 [] + + # 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 [] + + # 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 [] + + # Security + cmdline += ["-selinux", args.selinux] if args.selinux else [] + cmdline += ["-skip-adb-auth"] if args.skip_adb_auth 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 [] + + # 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 [] + + # 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}"] + 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 += ["-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 [] + + # 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 + + @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() + + # Prepare environment variables + 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(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 + env=env, + ) + + # 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: # noqa: C901 + if self._process is not None and self._process.returncode is None: + # First, attempt to power off emulator using adb command + try: + 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 + 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=20) + except TimeoutExpired: + 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.") + 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]: + 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 new file mode 100644 index 000000000..56b6ae9b0 --- /dev/null +++ b/packages/jumpstarter-driver-android/jumpstarter_driver_android/driver/options.py @@ -0,0 +1,202 @@ +from typing import Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, Field + + +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") + 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 + 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 + datadir: Optional[str] = None + image: Optional[str] = None # obsolete, use system instead + initdata: Optional[str] = None + + # 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 + 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" + 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 = 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]] = [] + no_location_ui: bool = False + no_hidpi_scaling: bool = False + 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) + 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 + 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 + 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 + 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) + 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 + grpc_allowlist: Optional[str] = None + idle_grpc_timeout: Optional[int] = None + grpc_ui: bool = False + + # 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] = 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] = {} + + class Config: + validate_assignment = True 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 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..2d5ce824b --- /dev/null +++ b/packages/jumpstarter-driver-android/pyproject.toml @@ -0,0 +1,33 @@ +[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", + "jumpstarter-driver-composite", + "jumpstarter-driver-network", + "jumpstarter-driver-power", + "adbutils>=2.8.7", +] + +[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/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) diff --git a/pyproject.toml b/pyproject.toml index ea5da01cd..717be13d9 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 784b2b99f..0ef492412 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ members = [ "jumpstarter-cli-admin", "jumpstarter-cli-common", "jumpstarter-cli-driver", + "jumpstarter-driver-android", "jumpstarter-driver-can", "jumpstarter-driver-composite", "jumpstarter-driver-corellium", @@ -63,6 +64,24 @@ docs = [ { name = "sphinxcontrib-programoutput", specifier = ">=0.18" }, ] +[[package]] +name = "adbutils" +version = "2.8.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "pillow" }, + { name = "requests" }, + { name = "retry2" }, +] +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/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]] name = "aiohappyeyeballs" version = "2.6.1" @@ -578,6 +597,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, upload-time = "2025-01-27T10:46:09.186Z" }, ] +[[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, 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, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -1051,6 +1082,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" }, @@ -1084,6 +1116,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" }, @@ -1248,6 +1281,40 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-android" +source = { editable = "packages/jumpstarter-driver-android" } +dependencies = [ + { name = "adbutils" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-network" }, + { name = "jumpstarter-driver-power" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "trio" }, +] + +[package.metadata] +requires-dist = [ + { name = "adbutils", specifier = ">=2.8.7" }, + { 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] +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" } @@ -3281,6 +3348,17 @@ 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, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "retry2" +version = "0.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, +] +wheels = [ + { 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]] name = "rich" version = "14.0.0"