mobile-agent-android
Host-side Android driver (design D11). So far it holds:
adb: the driver's own client for the adb server protocol (design D3, task 5.1).agent: deploying the resident agent, reusing or replacing a running one, and the connection to it (design D2 and D3, task 5.2).emulator: the client for the emulator's gRPC controller, the emulator fast path (design D7, task 5.4).session: an attached agent, and the controller when there is one, behind the mirror's backend traits (design D5 and D7, task 5.3), with path selection and the status report (task 5.5).benches/: the microbenchmark of the emulator frame hash,FrameHasher::keyof a synthetic 540x1200 frame (design D14 of themicrobenchmarkschange).cargo bench -p mobile-agent-android -- --noplotruns it, which needs no device. The library setsbench = false, so its test harness never sees criterion's flags.just microruns it with the other suites againstreports/micro/baseline.json.
adb client
adb::AdbServer talks to the local adb server (127.0.0.1, port 5037 or
ANDROID_ADB_SERVER_PORT) with one connection per request. It never spawns the
adb binary. Each connection has a 2 s connect deadline and a 10 s read and
write deadline, which AdbServer::with_io_timeout changes. The client is
blocking, so async callers run it on a blocking thread. macOS reports a read
that passes its deadline as EAGAIN, "Resource temporarily unavailable (os
error 35)", a would-block, which Error::is_timeout counts as a timeout.
Device::shell_until reads a command's output with the time left to a
deadline of the caller's instead, for a command silent for longer than the
read deadline, as am start -W while the app it launches starts: the
daemon's launch takes open's deadline so (android-pass-fixes, design D1).
| Operation | Protocol |
|---|---|
devices, targets |
host:devices-l, keeping every state; targets adds getprop facts for online targets and advice for the others |
Device::shell, shell_stream, shell_stream_with |
shell,v2,raw: with exit status and separate stderr, or shell,v2,pty: for a pseudo-terminal; shell closes stdin at once |
Device::shell_until |
as shell, the output read with the time left to the caller's deadline rather than under the read deadline; a command not ended by then, or a deadline already past, which runs nothing, fails with a timeout whose message names the deadline |
Device::push, push_bytes |
sync SEND in 64 KiB chunks, each header written with its data, then DONE and adbd's status |
Device::forward, forward_tcp, remove_forward, forwards |
host-serial:<serial>:forward:..., reading the second status and the port bound for tcp:0 |
Device::reverse, remove_reverse, reverses |
reverse:forward:... on the device transport, reading adbd's own status |
Device::open_service, open_abstract |
host:transport:<serial>, then the service, with no rule; a missing listener fails with closed |
The integration tests confirmed four behaviours on the API 36 emulator:
- adbd copies the owner's permission bits of a pushed file to group and other,
so 0o644 arrives as 0666 and 0o755 as 0777. The
adbbinary's pushes do the same. - Dropping a
ShellStreamends its command, because adbd sends it SIGHUP. - On a pipe, C programs such as
geteventbuffer their output in blocks; on a pseudo-terminal (Stdio::Pty) they flush every line. - A reverse rule makes adbd listen on its device side. The integration tests use this to get a live abstract socket with no process on the device.
Agent deployment
agent::Deployer deploys one agent build on one device. AgentJar is the jar
agent-android/build.sh writes (agent-android/dist/mobile-agent-android.jar,
or the path in MOBILE_AGENT_JAR), expected to report the version in
agent-android/VERSION, which the crate compiles in. Deployer::attach lists
the agent processes with ps -A -o PID,ARGS, where the agent shows as
mobile-agent <socket>, then:
- Reuse. When
Recordshold an agent for the serial and it still runs with the recorded pid and socket, the deployer connects with the recorded token and completes the handshake. If the host accepts the hello, it namesmobile-agent-android <VERSION>, and the record says the agent was started from the same build (AgentJar::build, the first 12 hex digits of an xxh3 digest of the jar), the connection is returned asOutcome::Reusedand no process starts. The agent's previous connection, if any, is replaced by this one. The build matters because the agent's payload can change without a version bump: the window metadata of task 3.6 came withVERSIONstill 0.1.0, and no capability in the hello says whether an agent sends it. - Replacement. A recorded agent that is not compatible is stopped before
a new one starts, and the launch reports it in
Launched::replacedwith the reason. An agent speaking no protocol version the host speaks is refused in the welcome withversion_mismatchand exits by itself. Another version, or another build of the same version (a record written before builds were recorded counts as one), is asked toshutdown. One that does not answer as recorded is killed. Whatever is still there afterexit_timeout(3 s) is killed by pid, after checking that/proc/<pid>/cmdlinestill names its socket. - Conflict. Any other agent process serves another host, so it is a
Conflictnaming it (mobile-agent, its pid and socket), reported at once without starting anything. WithDeployOptions::forcethe launch passes--force, and the new agent stops it along with any other known automation client. - Launch. Otherwise the jar is pushed and started:
- The jar goes to
/data/local/tmp/<socket>.jarwith mode 0o444. adbd copies the owner's permission bits to group and other (0o644 arrives as 0666), so read-only for everyone is the one mode that arrives unwidened and never writable. - The socket name is per launch and says which build listens on it:
mobile-agent-<version>-p<protocol>-<build>-<12 random hex digits>. The token is 16 random bytes as hex. - The launch runs
read -r t && (trap '' HUP PIPE; MOBILE_AGENT_TOKEN="$t" CLASSPATH=<jar> exec app_process / dev.mobileagent.agent.Main serve --socket <socket> --idle-exit-ms <ms> </dev/null &)on a shell v2 stream and writes the token to its standard input, so the token is on no command line. - The agent runs in a subshell's background, so it is reparented to init.
Dropping the stream makes adbd send SIGHUP to the shell it started,
which has exited, never to the agent, and the agent's later writes to
the closed stdout fail with EPIPE: ART blocks SIGPIPE in every thread
(
SigBlk0x80001204 on the API 36 emulator), and the command ignores SIGHUP and SIGPIPE as well, in case an adbd signals the whole process group.the_agent_outlives_its_launch_stream_and_a_closed_stdoutchecks the parent (pid 1), the ignored signals (SigIgn0x1001) and that a refused root peer, which makes the agent print, leaves it serving. - The stream is read until the ready line, which gives the pid and the
holder
--forcestopped, or the conflict line, which gives the likely holder and the agent's message. The stream is then dropped, the jar is deleted (the running agent keeps the dex it loaded) and the agent's socket is opened as an adb service stream with the token. - A hello that names another build than the jar should report means a stale jar: that agent is stopped and the error says to rebuild.
- The jar goes to
Records keep socket, pid, token, versions and build per serial, in memory, and in a
directory when the daemon names one (Records::in_dir, one file per serial,
mode 0600), so a restarted daemon reuses the agent its predecessor left
running. Attached::stop sends shutdown, waits for the exit, kills the
agent past the timeout and forgets the record; Deployer::stop_recorded does
the same without a connection. Deployer::state reads the agent state from
the process list alone, Absent, Running or Incompatible, from the
version, protocol and build in the socket name: connecting would take the
agent's connection away from its host.
agent::Connection drives the protocol crate's HostSession over the socket
on a reader thread. It answers pings, acts on deadlines, hands each response
to the caller waiting for it, and passes every other event to an EventSink;
events that arrive before the sink is set are replayed to it.
Measured by tests/measure.rs (release build, API 36 emulator, host load
about 12, n=10 each): a launched attach took 231.4 ms at p50 and 251.1 ms at
p95, of which the launch stream to the ready line 195.3 and 213.1 ms (the
agent taking UI automation and waiting for an active window); a reused
attach 15.3 and 16.8 ms, most of it the process listing. In
tests/agent_deploy.rs, replacing an older build took 281.0 ms, of which
stopping it 25.4 ms.
Agent session
session::Session::start takes an Attached agent and, optionally, a
ControllerLink (a connected controller on a two-thread Tokio runtime of its
own). It reads the display density with wm density (420 dpi on the API 36
emulator, a scale of 2.625 pixels per point, the override density first when
one is set). Session::backend hands a mirror::Backend over, once:
- Tree. Every read is one
snapshotrequest with a 10 s deadline; the agent reads every interactive window, so a read may return more windows than the mirror asked for. Reads after an attach, an action or a resync take the agent's idle bound (500 ms, ending 200 ms after the last event or action), and reads the target's own changes trigger (events, polls, frames) do not wait, since the mirror's settle replaces the platform's idle wait (design D8). The snapshot passes through as the agent sent it: its window metadata (whether each window's toolkit sends change events,falsefor Compose content; the system process that owns it; whether it is modal) and its scroll semantics are what the mirror reads. - Bounds probes (task 10.11). The tree source probes when the agent
advertises the
boundscapability (agent 0.3.0 on): a probe is oneboundsrequest for the nodes' ids with a 500 ms deadline, and each node the agent answers is mapped back to its window by its id, unique across the windows of one snapshot. A read forMovednames the windows whose nodes the probes found moved and asks the agent in itssnapshotrequest to evict their cache first (evict), without an idle wait. The mirror probes only while a target without frames settles, so the controller's path is unchanged. - Events. The connection's events reach the stream as they arrive, with
the host time: protocol events,
OutOfSyncandResyncedas themselves, and the close asDisconnectedwith the protocol crate's reconnect hint. A killed agent closes its socket, so the stream ends at once; a silent one is declared dead after 3 s by the protocol crate's liveness. The host session sends a resync the agent failed with a retryable error again after a backoff, until the stream is in sync (task 3.7); one it reports failed is sent again on the next read. - Input. Taps (a long press with a hold), swipes, keys and text go to the
agent in physical pixels with a 5 s deadline plus the gesture's duration;
text names the node of the latest read, and
autoleaves the method to the agent. With a controller, taps and swipes go through it instead (a swipe moves every 8 ms along its line, as the agent's does); keys and text stay with the agent. A swipe released resting (task 10.16) rests 60 ms at the end of its line, then moves one pixel on and lifts there, through the agent'srest_msor as the controller's last touches: Android's velocity tracker takes a pointer that has not moved for 40 ms as stopped when its next event comes, so the content stops where the pointer left it (a 600-point swipe over 300 ms moved the Expo fixture's list 555 to 584 points on the API 36 emulator's controller path, and 1,176 to 1,319 when it lifted moving). A touch that repeats the last position changes nothing the platform sees, hence the pixel. Input the agent did not inject leaves its cache of Compose windows stale and does not start its idle wait, so the next read waits until the input is 200 ms old and, when the last read showed a window that sends no change events, asks the agent in itssnapshotrequest to evict those windows' cache first (evict_event_less, task 3.7). The tree stream and the other windows' cache, which their events keep current, stay as they are, and noresyncis sent, so an agent without the events capability gets the eviction too. A controller that fails before a touch lands hands that input to the agent; after that, all input goes through the agent. - Frames, with a controller only. A task on the link's runtime subscribes
to 540-pixel-wide RGB888 frames and puts each on the event stream as a
Framewhose hash leaves the top 6 % of rows out (the status bar, spike S7), and whose capture time is its arrival minus the lag the emulator's timestamp shows, at most a second. The last four frames are kept; the frame source turns and scales one and encodes it with jpeg-encoder. The stream is subscribed to again when it fails.
Session::stop sends shutdown and kills the agent past the timeout;
Session::screenshot asks the agent. Session::reads, last_read,
inputs and frames_received count what went where.
Measured by tests/measure.rs (release build, API 36 emulator, host load
about 12), through a mirror on the Compose fixture's Big tab:
| Measure | p50 | p95 | n |
|---|---|---|---|
| First snapshot (573 nodes: one tree read, the agent's cold read with its idle wait) | 562.8 ms | 1 | |
| Warm snapshot from the replica, no device read | 0.4 ms | 0.5 ms | 100 |
| Tap on the Login tab to the tree showing it, agent path | 245.1 ms | 466.9 ms | 5 |
| Tap on the Big tab to the tree showing all 500 cells, agent path | 1561.2 ms | 1733.4 ms | 5 |
| Tap on the Login tab to the tree showing it, controller path | 283.2 ms | 299.3 ms | 5 |
| Tap on the Big tab to the tree showing all 500 cells, controller path | 1618.6 ms | 1891.6 ms | 5 |
| Killed agent to the mirror reporting it disconnected | 15.9 ms | 17.1 ms | 5 |
The time after a tap is the snapshot's own reads. The mirror reads the
Compose window again after the tap, no sooner than 200 ms after it (the
agent's idle wait after its own input, the session's wait after the
controller's) and with the window's cache emptied, so the read is cold: short
for the Login tab, long for the 573 nodes of the freshly composed Big tab. A
check under a host load near 190 found all 500 cells in the first snapshot
after the tap, which took 1.67 to 1.88 s and two or three reads (the action's
re-read, then the one-second poll that fell due meanwhile). In a debug build
of tests/agent_session.rs a tap was acknowledged in 7.8 to 11.3 ms through
the mirror, and a tab switch brought 17 to 42 controller frames in 3 s, most
with distinct hashes.
Path selection and status
session::select_paths(serial, emulator, choice, options) decides whether a
session uses the emulator's controller. emulator comes from the target
listing (Target::emulator); choice is ControllerChoice::Auto (resolve
the endpoint from the emulator's discovery file), Off, or Endpoint (a
given one). The resulting Selection goes to Session::start_selected.
| Target | Taps and swipes | Frames | Screenshots | Keys and text | Warning |
|---|---|---|---|---|---|
| Emulator whose controller connects | controller | controller stream | agent | agent | none |
| Emulator whose controller does not resolve or answer | agent | none | agent | agent | the launch flag and the console command |
Emulator with the controller turned off (the daemon's --emulator-controller off, task 10.12), or a device |
agent | none | agent | agent | none |
- Screenshots go through the agent on both paths: it returns them upright in every display orientation, and the controller's frames follow the emulator's own orientation, which this host did not check under a display rotated by the settings.
- Keys and text go through the agent on both paths. The controller accepts and drops keys unless the AVD declares a keyboard, and even then it cannot say whether the platform accepted a key; the status reports whether the AVD declares one.
- A controller that fails during the session moves input to the agent; the status then names the agent for input and the failure. A failed frame stream is subscribed to again and reported.
Session::status() names the agent and the active path of each kind, the
keyboard, the warning, and what failed since. On the API 36 emulator started
with -grpc 8554 -grpc-use-token:
emulator-5554: mobile-agent-android 0.1.0 over protocol 1, pid 2973, socket mobile-agent-0.1.0-p1-7fec0cb05341-9f3cf6e22dae, launched
input: emulator controller at http://127.0.0.1:8554 (taps, long presses and swipes)
frames: emulator controller, 14 received
screenshots: agent
keys and text: agent; the AVD declares no keyboard, so the controller would drop keys
With the controller's port closed:
input: agent (taps, long presses and swipes)
frames: none; screenshots come from the agent on demand
screenshots: agent
keys and text: agent
warning: emulator control endpoint unavailable for emulator-5554 (connecting to http://127.0.0.1:51770: transport error: tcp connect error: Connection refused (os error 61)): taps and frames fall back to the agent. Start the emulator with `-grpc 8554 -grpc-use-token` to use the fast path, or enable it without a restart through the emulator console command `grpc 8554`, for example `adb -s emulator-5554 emu grpc 8554`.
The tap measurements above compare the two paths: the controller's tap is followed by the session's 200 ms wait and a read with the agent's whole node cache emptied, where the agent's own tap empties only the Compose windows' cache, so the fast path does not make the tree change sooner.
Emulator controller client
An emulator started with -grpc <port> -grpc-use-token writes the port and a
bearer token into its discovery file, pid_<pid>.ini. On macOS the file is in
~/Library/Caches/TemporaryItems/avd/running; emulator::candidate_dirs lists
the directories for each OS. emulator::resolve(serial) matches the file's
port.serial against the serial's console port, and the newest file wins.
Controller::connect opens two HTTP/2 connections, one for status, screenshots
and frames and one for touches, and reads the hardware through getStatus.
It handles the quirks spike S7 found:
- Scaling needs both dimensions.
scaled_sizesets both, the height following the display's aspect ratio (540x1200 on a 1080x2400 display). - Raw rows arrive top-down, although the proto says bottom-up.
Frame::row(0)is the top row. - The display size is only in the deprecated
hardwareConfig.Hardware::from_statusreads it there unlessplatformConfighas it. - Some emulator ports accept TCP and never answer. Every unary call carries a
deadline, both as
grpc-timeoutand on the client side, and so do the connections and the opening of the stream. The stream then waits as long asFrameStream::nextis told to, since a still screen sends nothing. sendKeyaccepts keys and drops them when the AVD has no keyboard. The client sends no keys.Controller::keys_supportedreportshw.keyboardso the driver can route keys and text to the agent.- The stream repeats unchanged frames, and the status bar clock changes on its
own.
ChangeDetectorcompares xxh3 hashes that leave out the top 6 % of rows.
When the endpoint cannot be used, connect and resolve return Unavailable.
Its warning() names the launch flag, -grpc <console port + 3000> -grpc-use-token, and the console command that enables the endpoint without a
restart, grpc <port>, sent for example with adb -s <serial> emu grpc <port>.
The controller proto and CI
build.rs generates the client from the proto that the installed emulator
ships, because the AOSP copy is older. It looks for the proto in this order:
MOBILE_AGENT_EMULATOR_PROTO, the path of a proto file;$ANDROID_HOME/emulator/lib/emulator_controller.proto;- the same under
$ANDROID_SDK_ROOT; - the same under the SDK's default location (
~/Library/Android/sdkon macOS,~/Android/Sdkon Linux,%LOCALAPPDATA%\Android\Sdkon Windows).
protoc comes from protoc-bin-vendored. The crate was built and tested against
Emulator 37.1.11.0, whose proto has 1,865 lines and SHA-256
1d62c6bcad5f06621f90ec2bf26c661ba769ccd0f1416b5314d25a68e04eee5f.
emulator::PROTO_SOURCE records the path a build used.
CI needs the SDK's copy too, since a build without it fails and says how to
provide one. The actions/runner-images readmes list Android Emulator 37.1.11
on the macOS 26 image, under $ANDROID_HOME. The Ubuntu 24.04 image has the
SDK but not the emulator package, so the Linux job must install it before
building, with sdkmanager emulator, or point MOBILE_AGENT_EMULATOR_PROTO at
a copy of the SDK's proto.
Vendored emulator proto
proto/emulator_controller.proto is a copy of the SDK's
emulator/lib/emulator_controller.proto from emulator 37.1.11 (SHA-256
1d62c6bcad5f06621f90ec2bf26c661ba769ccd0f1416b5314d25a68e04eee5f). The build
prefers the installed copy and falls back to this one with a warning, so hosts
without the emulator package build. Refresh it when the emulator updates: copy
the SDK file over it and record the new SHA here.
Tests
Unit tests need no device:
The integration tests in tests/ run when MOBILE_AGENT_ANDROID_SERIAL names a
device on the local adb server. Without it, each test prints
skipped <test>: ... and passes, which is what CI does. The emulator tests
need an emulator started with -grpc <port> -grpc-use-token. Two bounds are
wall-clock, measured on a quiet developer Mac: the shell round trip under
50 ms at p50 in adb_device::shell_round_trip, and the conflict found in
under 1 s in agent_deploy::an_agent_another_host_started_is_a_conflict_until_forced;
with MOBILE_AGENT_HOST_GATES=off, as .github/workflows/device-tests.yml
sets it on GitHub's shared runners, each prints what it measured beside its
bound and asserts the rest.
MOBILE_AGENT_ANDROID_SERIAL=emulator-5554
What the adb integration tests do on the device:
- They push files named
/data/local/tmp/mobile-agent-it-*and delete them, retrying the deletion if the emulator's adb transport reconnects meanwhile. - They install forward and reverse rules on sockets named
mobile-agent-it-*. Forward rules are removed first. The reverse rule is removed only after every connection through it has closed, because the adb server aborts when a device-side connection arrives for a reverse rule it has already dropped (spike S6). - They start
sleep 60andttyin shell streams and check thatsleepends when its stream closes.
What the agent deployment tests (tests/agent_deploy.rs) do on the device:
- They start agents from the jar
agent-android/build.shwrote, with a 10 s idle bound, and stop each one at the end; a test that fails first kills its agents by pid, checking/proc/<pid>/cmdlineagainst their sockets. - The replacement test starts the current dex with every
mobile-agent-android 0.1.0rewritten to0.0.9and the dex checksum recomputed, pushed as a bare dex, whichapp_processloads like a jar. The agent has no option to report another version. The build test pushes the jar's own dex bare: the same version from other bytes. - The conflict test runs
uiautomator events, which holds UI automation for as long as it runs, and lets the forced attach stop it. - The detach test connects to the agent as root through
su 0, which the agent refuses and logs on its closed stdout; withoutsuit skips that part. - The tests hold UI automation for a few seconds each and run one at a time.
What the agent session tests (tests/agent_session.rs) do on the device:
- They start the Compose fixture (
am start -S) on a tab, wait until it is in front, and force-stop it at the end. - The full-stack test taps the Login tab through the mirror and kills its agent with SIGKILL; the others stop their agents.
- The controller test subscribes to frames and taps the List tab.
tests/measure.rs holds measurements rather than checks, ignored by default
and run on a quiet device:
MOBILE_AGENT_ANDROID_SERIAL=emulator-5554
It starts the Compose fixture on the Big tab and times launched and reused attaches, the first and warm snapshots through the mirror, taps between the Big and Login tabs on both paths, and killed agents; each line prints p50, p95 and the maximum.
What the path selection tests (tests/emulator_paths.rs) do on the device:
- They start the Compose fixture on the Big tab, tap its Login tab through the mirror, and force-stop it at the end.
- The fast-path test connects to the controller from the discovery file and encodes the latest frame; the fallback test points the endpoint at a closed local port and takes a screenshot through the agent.
The device may be shared. Before a test the agent and session tests wait,
up to a minute, until no agent runs and nothing holds UI automation. A test
that another user disturbs, by starting an agent or taking UI automation
(another agent's --force kills ours) or by moving the fixture, runs again
once the device is quiet, three times at most; a failure while nobody else
is on the device fails the test.
What the emulator integration tests do:
- They subscribe to frames and take raw screenshots, and they run
screencaponce to compare row order. - They tap twice on the status bar, where a tap changes nothing: at (250, 50) and (300, 70) on a 1080-pixel-wide display. Two different points are needed because evdev drops a position equal to the previous contact's.
getevent -lruns on a pseudo-terminal and watches the taps arrive. It receives SIGHUP when its stream closes, and the test checks that it is gone.
Measurements printed with --nocapture: shell_round_trip prints the p50, p95
and maximum of 100 echo ok round trips. frames_arrive_scaled_to_the_working_width
prints the first frame's latency and the frames of the next 3 s, which are none
when the screen is still. a_touch_lands_on_the_touchscreen prints the
sendTouch round trips. raw_screenshots_are_full_resolution_and_top_down
prints the p50 and p95 of 10 raw screenshots.
Spec scenarios and tests
Requirement Target discovery (android-driver spec):
| Scenario or clause | Tests |
|---|---|
| Scenario "Unauthorized device" | adb::tests::an_unauthorized_device_is_listed_with_instructions (scripted adb server) |
| Unauthorized and offline targets are listed with that state | adb::tests::devices_keep_every_state, adb::devices::tests::keeps_unauthorized_offline_and_authorizing_targets, adb::devices::tests::keeps_the_no_permissions_explanation |
| An actionable message for each unusable state | adb::devices::tests::unauthorized_advice_says_to_accept_the_prompt_on_the_device, adb::devices::tests::every_unusable_state_has_advice_and_an_online_device_has_none |
| Serial, model, API level and emulator flag | adb::devices::tests::an_online_target_takes_its_level_model_and_emulator_flag_from_getprop, adb::tests::a_target_whose_properties_cannot_be_read_stays_listed, integration targets_report_the_api_level_and_the_emulator_flag |
| Agent state (absent, running, incompatible) | agent::deploy::tests::no_agent_process_is_absent, agent::deploy::tests::an_agent_named_for_this_build_is_running, agent::deploy::tests::another_version_protocol_build_or_an_unknown_name_is_incompatible, agent::deploy::tests::a_compatible_agent_wins_over_an_incompatible_one, agent::launch::tests::agents_are_found_in_the_process_list_by_name_or_main_class, integration a_first_attach_starts_the_agent_and_installs_nothing (absent, running, absent after stop), an_incompatible_agent_is_replaced_by_the_current_build and an_agent_of_another_build_of_the_same_version_is_replaced (incompatible) |
Requirement Agent deployment without installation:
| Scenario or clause | Tests |
|---|---|
| Scenario "First attach" | integration a_first_attach_starts_the_agent_and_installs_nothing |
| Scenario "Compatible agent reused" | integration a_compatible_agent_is_reused_without_starting_a_process; agent::records::tests::records_in_a_directory_outlive_the_process_that_wrote_them |
| Scenario "Incompatible agent replaced" | integration an_incompatible_agent_is_replaced_by_the_current_build (another version) and an_agent_of_another_build_of_the_same_version_is_replaced (another build, as before and after task 3.6); agent::deploy::tests::an_agent_on_an_older_protocol_is_incompatible and agent::connection::tests::an_agent_speaking_only_an_older_protocol_is_refused_with_version_mismatch (an older protocol, refused so that the agent exits), agent::deploy::tests::a_recorded_agent_of_another_build_is_incompatible, agent::jar::tests::a_jars_build_is_a_digest_of_its_bytes, agent::records::tests::a_record_written_before_builds_were_recorded_has_an_unknown_build |
| Started as the shell user, no package, no instrumentation, no prompt | integration a_first_attach_starts_the_agent_and_installs_nothing (uid 2000, package list unchanged, jar deleted); agent::launch::tests::the_launch_command_keeps_the_token_off_the_command_line_and_detaches, integration the_jar_is_pushed_read_only and the_agent_outlives_its_launch_stream_and_a_closed_stdout |
| Reuse only of an agent proven by its token | agent::connection::tests::a_hello_with_another_token_is_refused, agent::deploy::tests::an_agent_that_does_not_answer_as_recorded_is_unreachable |
Requirement Single ownership of UI automation:
| Scenario or clause | Tests |
|---|---|
| Scenario "Another tool holds automation" | integration another_tool_holding_automation_is_a_conflict_naming_it_until_forced; agent::deploy::tests::a_conflict_names_the_holder_and_the_force_option, agent::launch::tests::a_conflict_line_names_the_holder_and_keeps_the_whole_message |
| An agent another host started is a holder too | integration an_agent_another_host_started_is_a_conflict_until_forced; agent::deploy::tests::an_agent_this_host_did_not_start_is_a_conflict_naming_it |
| No takeover without the force option | integration another_tool_holding_automation_is_a_conflict_naming_it_until_forced and an_agent_another_host_started_is_a_conflict_until_forced (conflict first, takeover only with force, Launched::took_over naming the holder); agent::launch::tests::force_and_extra_arguments_are_passed_quoted |
Requirement Agent lifecycle:
| Scenario or clause | Tests |
|---|---|
| Scenario "Daemon shutdown" | integration a_first_attach_starts_the_agent_and_installs_nothing (Attached::stop: no agent process, no record) |
| Scenario "App restarted" | integration the_same_agent_serves_the_mirror_after_the_app_is_force_stopped_and_relaunched (session tests); agent side lifecycle::the_same_agent_serves_snapshots_after_the_app_is_force_stopped_and_relaunched (agent-android host tests) |
| A killed agent reported disconnected (device-protocol "Liveness detection") | integration the_mirror_serves_compose_big_follows_a_tap_and_reports_a_killed_agent; session::tests::the_agent_going_away_is_reported_disconnected_with_a_reconnect_hint, session::events::tests::the_close_is_a_disconnection_with_a_reconnect_hint |
The scenarios below are served by the agent, whose checks are the
agent-android host tests (agent-android/README.md); the driver passes them
through, and these tests check that it does.
Requirement Tree acquisition across windows:
| Scenario or clause | Tests |
|---|---|
| Scenario "Keyboard is included" | agent side tree::the_keyboard_window_appears_with_its_keys_when_a_field_is_focused; driver session::tests::a_read_passes_the_agents_snapshot_through_with_the_scale_and_the_backend (every window of the read, the keyboard's included, reaches the mirror as the agent sent it) |
| Scenario "Node limit reached" | agent side tree::a_lower_node_cap_truncates_and_still_reports_the_full_count; driver session::tree::tests::reads_after_an_attach_action_or_resync_wait_for_idle_and_others_do_not (the node and depth caps reach the request), session::tests::a_read_passes_the_agents_snapshot_through_with_the_scale_and_the_backend (node count and serialized nodes kept apart) |
| Every interactive window, with the attributes of each node and the window metadata | integration the_mirror_serves_compose_big_follows_a_tap_and_reports_a_killed_agent (573 nodes in the replica, every cell with a ref); agent side metadata::a_permission_prompt_is_a_modal_window_owned_by_the_permission_controller, metadata::an_in_app_alert_is_a_modal_window_of_the_app_that_sends_no_change_events; driver session::tests::a_read_passes_the_agents_snapshot_through_with_the_scale_and_the_backend, session::tree::tests::event_less_windows_come_from_the_agents_metadata, session::tree::tests::the_scale_is_the_density_over_160_with_the_override_first |
Requirement Bounded idle waiting:
| Scenario or clause | Tests |
|---|---|
| Scenario "Continuous animation" | agent side idle::a_snapshot_during_an_endless_animation_returns_within_the_default_bound_plus_read_time; driver session::tree::tests::reads_after_an_attach_action_or_resync_wait_for_idle_and_others_do_not (the agent's bound after an attach or action, none for reads the target's own changes trigger) |
Requirement Change events:
| Scenario or clause | Tests |
|---|---|
| Scenario "Single field changes" | agent side events::editing_one_react_native_field_emits_events_naming_that_field_only; driver session::tests::events_reach_the_backends_stream_in_order, session::events::tests::protocol_events_and_sequence_notices_pass_through (the event reaches the mirror, which reads only that window) |
| Sequence gaps and resynchronization | session::tests::a_sequence_gap_reaches_the_mirror_and_the_stream_is_resynchronized |
| Compose windows, which send none, read again after an action | integration the_mirror_serves_compose_big_follows_a_tap_and_reports_a_killed_agent; session::tests::a_mirror_on_the_session_reads_a_compose_window_again_after_a_tap, session::tests::after_input_the_agent_did_not_inject_a_read_evicts_only_the_event_less_windows, session::tests::without_the_events_capability_a_read_after_foreign_input_still_evicts, session::tests::without_compose_foreign_input_only_delays_the_read |
| A read after a tap that takes a Compose screen away under the agent's own re-read, answered within its deadline (task 10.9) | integration a_snapshot_after_a_tap_that_tears_the_big_screen_down_under_the_agents_reread_is_answered_in_time; agent side teardown::a_snapshot_asked_for_during_a_compose_reread_is_answered_without_waiting_for_it, teardown::a_compose_reread_that_meets_a_torn_down_subtree_reads_the_new_screen_and_reports_the_change |
| Nodes that move without a change event, without frames (task 10.11) | session::tests::a_probe_asks_the_agent_for_the_nodes_and_maps_them_back_to_their_windows, session::tests::an_agent_without_bounds_probes_is_never_probed, session::tests::a_read_for_moved_nodes_asks_the_agent_to_evict_their_windows, session::tests::a_settle_without_frames_probes_the_agent_and_reads_the_moved_window_evicted; against an emulator, the daemon's devices::open_on_the_agent_path_waits_for_the_expo_screen_to_slide_in |
Requirement Input injection:
| Scenario or clause | Tests |
|---|---|
| Scenario "Tap acknowledged" | agent side input::a_tap_is_acknowledged_as_accepted_within_50_ms; integration the_mirror_serves_compose_big_follows_a_tap_and_reports_a_killed_agent (accepted through the mirror in 7.8 to 11.3 ms); session::tests::input_goes_to_the_agent_in_pixels_and_returns_its_verdict, session::input::tests::acknowledgements_carry_the_platforms_verdict_and_the_text_method |
| Taps, long presses, swipes with duration, keys, in pixels | session::input::tests::taps_and_long_presses_go_out_in_pixels, session::input::tests::swipes_keep_their_duration_and_keys_their_code, and on the controller session::input::tests::a_swipe_on_the_controller_is_down_moves_and_up_along_the_line, session::input::tests::a_swipe_without_duration_still_moves_once |
| Swipes released resting (task 10.16) | session::input::tests::a_swipe_released_resting_asks_the_agent_to_rest_60_ms, session::input::tests::a_swipe_released_resting_on_the_controller_rests_then_moves_a_pixel_on_and_lifts; the agent's input::a_swipe_that_rests_before_the_lift_moves_the_list_as_far_as_the_pointer |
Requirement Direct text setting with fallback:
| Scenario or clause | Tests |
|---|---|
| Scenario "React Native text input" | agent side text::a_focused_react_native_input_is_filled_directly_in_one_operation; driver session::input::tests::text_names_the_node_of_the_latest_read_and_its_method, session::tests::input_goes_to_the_agent_in_pixels_and_returns_its_verdict (the direct method comes back) |
| Scenario "Element rejects direct setting" | agent side text::a_focused_element_without_direct_setting_gets_key_events_and_the_fallback_is_named; driver session::input::tests::acknowledgements_carry_the_platforms_verdict_and_the_text_method (the key method comes back) |
Requirement Screenshot capture:
| Scenario or clause | Tests |
|---|---|
| Scenario "Landscape screenshot" | agent side screenshot::a_landscape_screenshot_has_landscape_dimensions_and_the_display_orientation; driver session::tests::screenshots_go_through_the_agent |
Requirement Emulator fast path (android-driver spec):
| Scenario or clause | Tests |
|---|---|
| Scenario "Control endpoint enabled": taps and frames are served through it and the target status names the fast path | integration with_the_controller_taps_and_frames_use_it_and_the_status_names_the_fast_path (a tap through the mirror counted on the controller, frames received and encoded, the status lines); session::paths::tests::the_status_names_the_fast_path_and_the_keyboard; the controller client's integration frames_arrive_scaled_to_the_working_width, a_touch_lands_on_the_touchscreen and the session's controller_frames_arrive_on_the_event_stream_with_status_bar_free_hashes |
| Scenario "Control endpoint disabled": taps and screenshots are served through the agent and the status carries a warning with the launch flag | integration without_the_controller_taps_and_screenshots_use_the_agent_and_the_status_warns (a closed port; the tap counted on the agent, a screenshot from the agent, no frame, the warning in the status); session::paths::tests::an_emulator_whose_controller_does_not_answer_falls_back_with_the_launch_flag, session::paths::tests::the_status_names_the_agent_path_and_carries_the_warning; the controller client's integration a_closed_port_falls_back_with_the_launch_flag_and_console_command, emulator::controller::tests::a_closed_port_is_unavailable_with_the_fallback_warning, emulator::endpoint::tests::no_discovery_file_is_unavailable, emulator::endpoint::tests::a_discovery_file_without_a_grpc_port_is_unavailable |
| The console can enable the endpoint at run time without a restart | emulator::endpoint::tests::the_warning_names_the_launch_flag_and_the_console_command, and the warning in both path tests above |
| Keys and text go through the agent unless the emulator declares a keyboard | keys and text always go through the agent: session::paths::tests::the_agent_paths_send_everything_to_the_agent, session::paths::tests::the_status_names_the_fast_path_and_the_keyboard (the keyboard reported), emulator::hardware::tests::keys_go_through_the_controller_only_with_a_declared_keyboard, integration the_display_size_and_keyboard_match_the_avd |
| The driver reports which path is active | both path tests above; session::paths::tests::the_status_names_the_agent_its_version_and_whether_it_was_reused, session::paths::tests::a_controller_failure_and_a_lost_agent_are_reported |
| A device, or an emulator with the controller turned off, uses the agent without a warning | session::paths::tests::a_device_uses_the_agent_without_a_warning, session::paths::tests::an_emulator_whose_controller_is_turned_off_uses_the_agent_without_a_warning |
Requirement A launch takes the command's deadline (android-pass-fixes, design D1):
| Scenario or clause | Tests |
|---|---|
| Scenario "A cold launch slower than the read timeout" | adb::tests::a_shell_command_silent_past_the_read_timeout_completes_within_its_deadline (scripted adb server, a 200 ms read deadline); the daemon's devices::android::tests::a_cold_launch_silent_past_the_read_timeout_opens_within_the_command_s_deadline (a fake adb server serving Chrome's cold launch), handlers::act::tests::open_launches_the_app_within_its_own_deadline and handlers::act::tests::a_second_launch_takes_the_explicit_deadline_else_the_wait_again_from_its_start |
| Scenario "A launch past the deadline" | adb::tests::a_shell_command_still_silent_at_its_deadline_fails_with_a_timeout_at_the_deadline; the daemon's devices::android::tests::a_launch_still_silent_at_the_command_s_deadline_fails_naming_the_launch_and_the_deadline (deadline_exceeded, never os error 35) |
Task 5.4's own checks:
| Check | Tests |
|---|---|
| Token discovery | emulator::discovery::tests::*, emulator::endpoint::tests::a_discovery_file_with_a_grpc_port_resolves_to_an_endpoint, integration discovery_finds_the_endpoint_and_its_token and a_call_without_the_token_is_refused |
| A second connection for input | emulator::controller::tests::input_travels_on_a_second_connection |
| RGB888 scaled with both width and height set | emulator::hardware::tests::scaling_sets_both_width_and_height, emulator::hardware::tests::the_height_keeps_the_aspect_ratio_rounded_up, emulator::hardware::tests::no_scaling_requests_the_display_size_as_zeros, integration frames_arrive_scaled_to_the_working_width |
Raw getScreenshot |
integration raw_screenshots_are_full_resolution_and_top_down |
| A deadline on every call | emulator::controller::tests::a_silent_endpoint_fails_at_the_deadline |
| Top-down rows | emulator::frame::tests::rows_are_top_down, emulator::frame::tests::the_status_bar_is_at_the_start_of_the_buffer, integration raw_screenshots_are_full_resolution_and_top_down |
The display size from hardwareConfig |
emulator::hardware::tests::the_display_size_comes_from_the_lcd_keys, emulator::hardware::tests::the_status_falls_back_to_the_deprecated_hardware_config, integration the_display_size_and_keyboard_match_the_avd |
| The frame-hash detector without the status bar | emulator::frame::tests::a_change_in_the_status_bar_is_unchanged, emulator::frame::tests::one_changed_pixel_is_a_change_and_becomes_the_new_reference, emulator::frame::tests::a_dimension_change_is_a_change |
Task 5.3's own checks: the frames in session::controller::tests::*
(status-bar-free hashes, capture time, clockwise rotation, area-average
scaling, JPEG size, the kept frames), integration
controller_frames_arrive_on_the_event_stream_with_status_bar_free_hashes,
and session::tests::a_failed_read_keeps_the_agents_code,
session::tests::stop_asks_the_agent_to_shut_down,
session::tests::the_backend_is_taken_once.
Task 5.2's own checks: the connection's reader thread in
agent::connection::tests::* (replies, concurrent callers, events before the
sink, pings while idle, the end of the stream), the launch parsers in
agent::launch::tests::* and the records in agent::records::tests::*.
Task 5.1's own checks against the emulator, in tests/adb_device.rs:
listing_shows_the_device, shell_v2_keeps_the_exit_status_and_stderr_apart,
a_command_reading_stdin_sees_end_of_file,
closing_a_shell_stream_ends_the_command,
a_pty_stream_runs_the_command_on_a_terminal,
push_writes_the_bytes_with_the_mode, push_copies_a_local_file,
a_push_adbd_refuses_reports_its_message, a_forwarded_port_connects,
a_service_stream_reaches_a_live_abstract_socket,
a_service_stream_to_a_missing_socket_fails_with_closed,
a_reverse_adbd_refuses_reports_its_error and
a_missing_serial_fails_with_the_servers_message.