Android and iOS devices
AutoControl drives Android devices through adb (plus the optional
uiautomator2 daemon) and iOS devices through WebDriverAgent (the
facebook-wda client). Both are optional: nothing here is imported until
it is used, so the package still imports on a host with neither.
Warning
Not verified on hardware. Everything on this page is built and tested
against a fake ADB host and a fake WebDriverAgent client
(test/unit_test/headless/_mobile_doubles.py). Those tests prove the
commands AutoControl sends are the ones it means to send; they do not prove
a real emulator, phone or WebDriverAgent build accepts them. No CI job
attaches a device. Treat the setup notes below as the intended procedure,
not as a tested one.
Device contexts and sessions
A DeviceContext is the frozen identity of one device, and
open_device() turns it into a DeviceSession that owns its own
transport, timeout and cancellation signal:
from je_auto_control import DeviceContext, open_device
left = open_device(DeviceContext("android", "emulator-5554"))
right = open_device(DeviceContext("ios", "http://192.168.1.20:8100"))
print(left.capabilities()["unicode_text"].state)
left.cancel() # stops ``left`` only
assert right.connected
right.close()
device_id is the adb serial on Android and the WebDriverAgent URL on iOS.
Opening a session sends nothing: the adb client or WDA client is built on the
first operation, so a session can be opened on a host without adb.
cancel()stops one session. A caller waiting on the device returns at once withDeviceCancelledErrorand nothing further is sent. A call already on its way to the device cannot be recalled.A call that outlives
timeout_s(default 30 s) raisesDeviceTimeoutErrorand closes the session: the device is in a state nobody observed, so the next step is refused (DeviceClosedError) instead of being sent on top of it. Open a new session to continue.close()releases what the session built. A client handed in by the caller (open_device(context, adb=...)/device=...) is used but not owned. Closing an iOS session never terminates an app or deletes a remote WDA session.
Several devices in one process
use_device(session) binds a session for the current thread; a mobile
command that names no serial / url then targets it. The device matrix
does this for every device it runs, so two workers cannot reach each other’s
device by leaving the address out:
from je_auto_control import run_on_devices
run_on_devices(
actions=[["AC_android_tap", {"x": 100, "y": 200}]],
devices=[{"platform": "android", "serial": "emulator-5554"},
{"platform": "android", "serial": "emulator-5556"}],
)
An explicit serial / url in a step still wins over the bound device.
The older process-wide helpers (default_ui_device(),
default_ios_device(), the executor’s per-serial adb cache) keep working
for single-device scripts; the matrix no longer touches them.
Capabilities
session.capabilities() returns one DeviceCapability per feature —
input, unicode_text, multi_touch, screenshot, ui_tree,
app_lifecycle, alerts, install, files, clipboard,
recording — each in one of four states:
State |
Meaning |
|---|---|
|
Usable now. |
|
The device refused the host (USB debugging not authorised). |
|
Something is missing; |
|
The backend cannot do it; |
Probing sends no input. On Android it runs adb devices and reads one
setting; on iOS it requests WDA’s /status.
Errors
Every error derives from DeviceError (an AutoControlException and a
RuntimeError), whichever backend raised it:
Error |
Raised when |
|---|---|
|
|
|
The device has not authorised this host ( |
|
A call outlived the timeout ( |
|
The session was cancelled. |
|
The session was closed or broken by a timeout. |
|
The backend cannot do what was asked; carries |
AdbError and the other pre-existing names are still raised and still
caught by existing except clauses; they now share this base.
Text
session.type_text(text) delivers the text or raises; it never reports
success for text the device did not receive.
Android. adb shell input text carries printable ASCII only. It drops
or mangles everything else, turns every %s into a space, and exits 0
either way. So:
Printable ASCII without a literal
%sgoes throughinput text.Anything else goes to the ADBKeyBoard IME when it is the device’s selected input method (
adb shell ime set com.android.adbkeyboard/.AdbIME).Otherwise it goes to
uiautomator2when that is installed on the host.With neither,
AdbUnsupportedError(aDeviceUnsupportedError) is raised and nothing is sent.
AC_android_text follows the same route. AdbClient.text() itself now
refuses text input text cannot deliver instead of sending it.
Note
The ADBKeyBoard broadcast returns the same result whether or not an IME consumed it, so delivery is inferred from the IME being selected, not confirmed by the device.
iOS. WebDriverAgent’s key endpoint carries Unicode; the text is passed through unchanged.
Gestures
session.perform(gesture) takes one of five frozen dataclasses, all in
device input coordinates:
Gesture |
Android |
iOS |
|---|---|---|
|
|
WDA tap |
|
|
WDA touch-and-hold |
|
|
WDA drag |
|
|
WDA drag with |
|
|
Pinch on the frontmost application. WDA pinches an element, not a
coordinate, so |
Frames and coordinates
session.capture() returns a DeviceFrame: the screenshot, upright,
together with the size of the input coordinate space.
On iOS WebDriverAgent takes points and returns pixels (three per point on most iPhones).
frame.pixel_to_point(x, y)converts; tapping a screenshot pixel as if it were a point lands in the wrong place.On Android
screencapandinput tapshare one coordinate space and the mapping is the identity. This is checked againstwm sizeand the display rotation rather than assumed.A screenshot that comes back in the panel’s natural orientation while the display is rotated is turned upright before anything is located in it. Which way to turn it (270 degrees for
landscape_left, 90 forlandscape_right) follows the platforms’ documented conventions and has not been checked on a device. An upside-down display cannot be told from the image shape and is taken as already upright.
Locating
A frame is searched directly — the host’s desktop is never captured — and every answer is in device points, ready for a gesture:
from je_auto_control import Tap, self_heal_locate
frame = session.capture()
session.perform(Tap(*frame.locate_image("login_button.png")))
point = frame.locate_text("Sign in") # OCR, or None
point = frame.locate_description("the blue button") # VLM, or None
outcome = self_heal_locate(template_path="login_button.png",
description="the login button", frame=frame)
if outcome.found:
session.perform(Tap(*outcome.coordinates))
self_heal_locate(..., frame=frame) runs the same template-then-VLM
fallback and writes the same heal log; screen_region does not apply to a
frame. self_heal_click still clicks the desktop mouse and takes no frame.
App lifecycle and alerts
from je_auto_control import (
AppState, accept_alert, launch_app, stop_app, wait_for_app,
)
launch_app(session, "com.example.shop") # Android package / iOS bundle id
wait_for_app(session, "com.example.shop", timeout_s=15)
accept_alert(session)
assert stop_app(session, "com.example.shop") == AppState.NOT_RUNNING
AppState is not_installed, not_running, background or
foreground, and compares equal to those strings.
Call |
Android |
iOS |
|---|---|---|
|
|
WDA app launch |
|
|
WDA app terminate |
|
|
WDA app state. WDA reports an app that is not installed as not
running, so |
|
Polls |
Same. |
|
Presses the standard dialog or permission button through
|
WDA alert accept / dismiss; returns the alert text. |
With no alert showing, both raise AlertNotPresentError. An app id is
validated before it reaches the device shell.
A device-matrix spec may name an app_id: the app is launched and in front
before the first step and stopped afterwards whether the steps passed or not
("keep_app": true leaves it running). DeviceResult.app_state records
where it ended up.
Install, files, clipboard, recording
These are the features a backend may not have. mobile_extension(session)
returns a MobileExtension; capability(feature) says whether each
of install, files, clipboard and recording can be used, and a
method whose feature is missing raises DeviceUnsupportedError with the
reason:
from je_auto_control import mobile_extension
extension = mobile_extension(session)
if extension.capability("recording").available:
extension.start_recording(time_limit_s=60)
...
extension.stop_recording("run.mp4")
Feature |
Android |
iOS (WebDriverAgent) |
|---|---|---|
|
|
|
|
|
|
|
Through |
Set only. Reading is allowed by WDA only while WDA itself is in front,
and raises |
|
|
|
Nothing from adb is used for an iOS device. To add the missing iOS
features, register an adapter around a host-side tool; it is asked first and
WebDriverAgent covers what it does not provide:
from je_auto_control import register_mobile_extension
register_mobile_extension("ios", lambda session: MyTideviceAdapter(session))
No such adapter ships with AutoControl.
Commands, MCP tools, Script Builder, GUI
Every mobile command is described once, in
je_auto_control.MOBILE_COMMANDS (wrapper/mobile_commands.py). The
executor’s AC_android_* / AC_ios_* commands, the ac_android_* /
ac_ios_* MCP tools, the Script Builder’s Android and iOS
categories and the Mobile tab are all generated from that table, so a
command cannot exist on one surface and be missing from another
(test_mobile_surface_parity.py fails if it does).
Each command takes an optional address — serial and adb_path on
Android, url on iOS — plus device_timeout_s. Without an address it
runs on the session bound by use_device (a device-matrix worker), else on
the backend’s default device. Unknown parameters are rejected.
Group |
Android |
iOS |
|---|---|---|
Device |
|
|
Input |
|
|
Screen and locating |
|
|
Apps and alerts |
|
the same six under |
Extensions |
|
the same seven under |
Shell |
|
none: there is no shell to run on iOS |
The _find_* and _self_heal commands take tap to tap what they
find. AC_android_shell runs an arbitrary command on the device and is
deliberately not offered as an MCP tool.
From Python the same table is reachable as
run_mobile_command(name, params) and mobile_capability_matrix(); the
latter also lists the desktop features with no mobile counterpart (window
management, mouse buttons and wheel, keyboard shortcuts, the desktop
accessibility tree, COM, USB host passthrough, global hotkeys, desktop
capture) with the limitation and the alternative for each. Its
capabilities rows hold the commands that deliver a device capability;
other_commands holds the ones that deliver none, by purpose –
device_info (AC_android_list_devices, AC_android_device_info,
AC_ios_device_info: they describe devices and send no input) and shell
(AC_android_shell). Those four used to be listed under input.
Mobile tab. Pick the platform, enter the serial or WebDriverAgent URL, choose a command and edit its parameters as a JSON object. Probe device, Run command and Fill parameter template are in the Actions menu. Probing shows the capability table and the setup report and sends no input. Both actions run on the GUI thread and block it until the device answers or the timeout passes.
Setup
Warning
Untested on hardware. These are the steps the code is written for; none of them was carried out against a real emulator, phone or WebDriverAgent.
Android emulator or device
Install Android platform-tools and put
adbonPATH(or passadb_path).Emulator: start an AVD; it appears as
emulator-5554. Device: enable Developer options and USB debugging, connect it, and accept the authorisation prompt. Over Wi-Fi:adb connect <ip>:5555.adb devicesmust show the serial in statedevice.unauthorizedsurfaces here asneeds_permission/DevicePermissionError.Optional, for the widget tree, pinch, dialogs, clipboard and Unicode text:
pip install uiautomator2.Optional, for Unicode text without
uiautomator2: install ADBKeyBoard and select it withadb shell ime set com.android.adbkeyboard/.AdbIME.Check:
AC_android_device_info(or the Mobile tab’s Probe device) reports the adb build, the Android release and each capability.
iOS device or simulator, local or remote WebDriverAgent
Building and signing WebDriverAgent needs a Mac with Xcode; for a physical device it also needs an Apple developer signing identity. AutoControl does not build or install it.
On the Mac, build and run WebDriverAgent on the device or simulator (
xcodebuild ... teston theWebDriverAgentRunnerscheme) and make port 8100 reachable —iproxy 8100 8100for a USB device.On the host that runs AutoControl (any OS):
pip install facebook-wda.Local: the URL is
http://localhost:8100. Remote: use the Mac’s or the device’s address, for examplehttp://192.168.1.20:8100. WebDriverAgent has no authentication, so keep it on a trusted network or behind a tunnel.Check:
AC_ios_device_infowithurlreports the WebDriverAgent build and the iOS version from/status; an endpoint that does not answer is reported asneeds_dependencywith the connection error.
Driving a remote WebDriverAgent from Windows or Linux is the supported way to use iOS from those hosts. That is not a claim that Xcode builds or signing were verified on them.