To run scenarios
adb (Android SDK platform-tools) and either a running emulator or an
attached device. No JDK, no Gradle, no Android SDK build tools.
Tales drives Android through Google’s own tooling and a repository-owned Kotlin/UiAutomator HTTP driver. There is no Appium server, no Maestro runtime, and no third-party dependency inside the on-device driver.
The DSL is the same one the iOS provider uses.
A scenario moves between platforms by changing platform and the target’s
device fields; the actions, expectations and locators are identical.
Running Android scenarios needs only adb and a device. The driver APKs are
built ahead of time, committed, and embedded in the tales binary, so nothing
compiles at test time.
To run scenarios
adb (Android SDK platform-tools) and either a running emulator or an
attached device. No JDK, no Gradle, no Android SDK build tools.
To change the driver or the demo app
JDK 17 and the Android SDK, plus the committed Gradle wrapper. Only
contributors touching drivers/android/ need this.
brew install --cask android-platform-tools android-commandlinetools
export ANDROID_HOME="$HOME/Library/Android/sdk"sdkmanager --sdk_root="$ANDROID_HOME" \ "platform-tools" "emulator" \ "system-images;android-34;default;arm64-v8a"
avdmanager create avd -n tales-e2e \ -k "system-images;android-34;default;arm64-v8a" --device pixel_6
export PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"Prefer the default (AOSP) image over google_apis unless the app under test
needs Play Services. The Google APIs image spends its first minutes updating
them, and that work competes with your scenarios: it slows APK installs to tens
of seconds, and it can relaunch the app under test, which rewinds it to its
first screen mid-scenario. Tales then reports an element that was on screen a
moment earlier as missing. Tales’ own Android suite moved to the AOSP image for
exactly that reason and runs about twice as fast on it.
.tales step → mobile provider (shared with iOS) → android backend adb, am instrument, screenrecord → adb forward host port ⇄ device port 9080 → UiAutomator driver (Kotlin, on device)The driver is an androidx.test.uiautomator instrumentation serving the same
HTTP/JSON contract as the XCUITest driver: same routes, same payload keys, same
status codes. That shared contract is what lets one Go client and one set of
assertions serve both platforms.
Tales owns the device lifecycle: it selects the device, installs the driver when
its build changed, allocates a free host port, forwards it, starts the
instrumentation, and waits for /health before the first step runs.
config { mobile = { targets = { phone = { platform = "android" device_name = "tales-e2e" # AVD name serial = env("ANDROID_SERIAL", "") # optional, pins a device app = "./app/build/outputs/apk/debug/app-debug.apk" app_id = "com.example.app" # package name } } }}| Field | Meaning |
|---|---|
platform | "android" |
device_name | AVD name. Optional when serial is set. |
serial | adb serial (emulator-5554). Pins one device. |
app | Path to the .apk to install. |
app_id | Application id (package name). |
driver.external | Connect to a driver you started yourself. |
driver.port | Host port. Auto-allocated when omitted. |
driver.adb_path | Override adb discovery. |
driver.timeout | Duration string bounding every driver request. Default 30s. |
driver.timeout is shared with the iOS provider and matters for the same
reason: on a slow or shared host a single app launch can outlast the default,
and a launch the client abandons but the driver completes leaves the two sides
disagreeing for the rest of the run. See
the iOS notes.
Selection is deliberately deterministic, because a run that silently picks a different device between invocations is worse than one that refuses to guess:
serial must exist and be ready.serial.Tales waits for sys.boot_completed, not just adb wait-for-device: the latter
only waits for adbd, which answers well before the framework is up, and
installing or launching in that window fails confusingly.
| Locator | Android source |
|---|---|
id | android:id resource id, or a Compose testTag |
label | contentDescription |
text | the element’s visible text |
id matches the short form (login_button), so a locator does not depend on
the application id. The fully qualified com.example.app:id/login_button is
accepted too.
Identical to iOS — see the action reference. The platform differences are:
| Android | |
|---|---|
press_button | home, back, recent_apps, power, volume_up, volume_down. lock is accepted as an alias for power. |
set_orientation | portrait, landscape_left, landscape_right. upside_down is rejected: Android exposes the rotation but almost no app declares support, so it would appear to succeed while nothing rotated. |
record | bit_rate and size; the simctl options (codec, mask, display) are rejected. |
scroll_to | Scrolls the nearest scrollable ancestor with the accessibility scroll action, rather than dragging the window. Reaches elements above the viewport as well as below. |
The DSL accepts the union of both platforms’ values and the backend rejects what it cannot honour, naming the platform and listing what it does take. That keeps a shared scenario writable while making the failure obvious:
button "back" is not supported on ios (supported: home, lock)scroll_to { id | label | text } is a no-op when the element is already on
screen, and equally when the screen has nothing to scroll but the element is
visible anyway. It fails when the element is not on screen once the driver has
finished trying — whether because the locator matches nothing, because nothing
can scroll, or because the container ran out of travel.
That last part is stricter than it used to be, and than iOS still is. The driver
used to answer success on the strength of the element merely existing in the
tree, so any early exit reported a scroll that had not happened: the following
wait_visible then hunted for an element on a screen that never moved, and
timed out pointing at the app (issue #71). A refusal fails on the action that
caused it; a wrongful success fails later, somewhere else.
That matters more on Android than on iOS, because a Compose container publishes
itself as scrollable only once its content overflows the viewport. On a screen
whose content fits there is no scrollable container in the accessibility tree at
all — so treating “nothing to scroll” as an error would make the same scenario
pass or fail depending on the device’s screen size. Write scroll_to before an
interaction and let the following tap or input_text report the real problem
if the element never appears.
Elements above the current position are reachable too: the driver scrolls backward when the element sits above the viewport, and flips direction when a container reports it cannot travel any further the way it first tried.
When it does give up, the failure says which of the three endings it hit —
nothing on screen can scroll, the element has no scrollable ancestor, or the
container cannot travel any further — because element not found on its own
reads as a statement about the app when it is often a statement about the
container.
“On screen” here means what the hierarchy dump means by visible, ancestors
included. A node can pass the check on its own while sitting under a clipped
container that keeps the whole subtree out of every snapshot, and taking that
for “already there” is what produced the wrongful success above.
An element the tree does not hold yet is not a failure either. Tales retries the
whole action for timeout (default 10s, interval sizes the gap), so
scroll_to can be written straight after the tap that opens a screen still
being built. Before that it dispatched once and failed on the spot, which made
the natural ordering — scroll, then wait — lose a race the author could not see:
the wait_visible never ran, because the scroll had already failed.
tap { id = "feed.row.3" }
scroll_to { id = "detail.delete" timeout = "20s"}wait_enabled / wait_disabled poll the accessibility node’s enabled flag,
which Android maps straight from AccessibilityNodeInfo.isEnabled. A Compose
control disabled through enabled = false reports it, so the wait works on
Compose and View-based UI alike.
actions { input_text { id = "login.password" value = "Secret123!" secure = true } wait_enabled { id = "login.submit" timeout = "10s" } tap { id = "login.submit" }}This matters on Android beyond the app’s own async work: ACTION_SET_TEXT
returns as soon as the node accepts the text, while the recomposition that
re-evaluates the button’s enabled lambda happens on the next frame. A tap
issued in that window lands on a control that is still disabled, and Android
swallows it without an error.
Text is written with the accessibility ACTION_SET_TEXT rather than synthesised
keystrokes. It is atomic, handles any Unicode, and does not depend on a soft
keyboard being up. Keystrokes remain a fallback for widgets that refuse the
action, and there non-ASCII input fails loudly rather than silently mangling.
Every write is read back. A field truncated by a maxLength or an input filter
fails on the step that wrote it, rather than surfacing two steps later as a
confusing assertion failure. A field that reports no text at all — a Compose
password field, for instance — is treated as unverifiable and trusted.
clear_text erases the field its locator names, through the same accessibility
action, so it is atomic and cannot reach a different field. Widgets that refuse
the action fall back to delete keystrokes, and because keystrokes go to whatever
holds input focus rather than to the element asked for, that fallback runs only
once the target actually holds focus. When it does not, the step fails saying
so instead of erasing whichever field does. A locator matching no element is a
failure too, not a silent no-op.
permissions { camera = "allow" location = "deny"}Services are named semantically, exactly as on iOS, and expanded per platform:
| Service | Android permissions |
|---|---|
camera | CAMERA |
microphone | RECORD_AUDIO |
location | ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION |
contacts | READ_CONTACTS + WRITE_CONTACTS |
calendar | READ_CALENDAR + WRITE_CALENDAR |
photos | READ_MEDIA_IMAGES + READ_MEDIA_VIDEO (API ≥ 33), else the storage pair |
notifications | POST_NOTIFICATIONS (API ≥ 33; a no-op below, where it is granted at install) |
A service that means several permissions is granted as a set: a scenario asking
for location means the capability, not one half of it. An install-time
permission that cannot be toggled is treated as already satisfied rather than an
error.
launch { clear_state = true }Runs pm clear, which wipes data, cache, accounts and granted runtime
permissions in one call — the whole of what iOS needs an uninstall plus a
keychain reset plus a reinstall to achieve, and much faster since the app stays
installed.
scenario "preview" { record { output = "preview.mp4" bit_rate = "8M" }}Backed by adb shell screenrecord. Stopping signals SIGINT and never SIGKILL:
screenrecord writes the MP4 trailer on interrupt, and a killed process leaves a
file no player opens. The file is then pulled off the device.
--time-limit 0 (unlimited) is only used from API 34; older devices cap at three
minutes. Some emulator images do not support screenrecord at all.
| Target | Needs |
|---|---|
make e2e-android | adb + a device |
make e2e-android-failure | adb + a device |
make doctor-android | nothing |
make build-android-demo | JDK 17 + SDK |
make build-android-driver | JDK 17 + SDK |
make check-android-driver-fresh | JDK 17 |
A failing step writes, next to each other:
build/artifacts/mobile/<scenario>-<hash>/<step>/<phase>/attempt-N/ screenshot.png what was on screen hierarchy.json what the accessibility tree held device.log what the system thought it was doingdevice.log is an unfiltered logcat -d dump, and it is the one that settles questions the other two cannot. A launcher ANR, an app that crashed on start and an app that is merely slow to draw all look identical from a screenshot: a blank screen and a hierarchy holding nothing but the status bar. Two CI runs were lost to exactly those before the dump existed, with no way to tell them apart.
It is captured last and never fails a step: a device that has gone offline simply yields no device.log, leaving the other artifacts intact.
adb executable not found — install platform-tools and set ANDROID_HOME, or
point driver.adb_path / the ADB_PATH variable at the binary.
no ready Android device — start the emulator:
$ANDROID_HOME/emulator/emulator -avd tales-e2e.
device not ready after 180s — the emulator answers adb but is not usable
yet. make e2e-android waits for that through scripts/android-wait-ready.sh
(boot completed, boot animation stopped, package manager answering) before
touching the device. A freshly booted emulator keeps scanning packages and
running dexopt well past sys.boot_completed, and driving UI inside that window
makes every hierarchy fetch outlast the scenarios’ action timeouts — the suite
then fails naming the elements it was looking for, pointing nowhere near the
device that was not ready. Raise the budget with ANDROID_READY_TIMEOUT
(seconds) on a slow host.
N devices are attached — set serial on the target. Tales will not guess.
element ... was not disabled after 10s (41 reads, always enabled=true, resolved type="other" with children [static_text, button]) — a failed enabled /
disabled expectation reports how many times it read the element, whether the
reading ever moved, and what the node that answered looks like. Read it before
blaming the assertion: a reading that never moved across dozens of polls is a
state that really did not change, or a tree that never refreshed, and neither is
a timing problem a longer timeout would fix. The node description matters on
Compose, where a control is published as a wrapper around its own button — the
wrapper is the node that carries the state, and the inner one reports
enabled=true whatever the control does.
the Android driver APKs are not embedded in this build — a working tree that
has never built the driver. Run make build-android-driver, or set
driver.external = true to connect to a driver you started yourself.
A step fails with a bare EOF — the driver died mid-request. The step report
carries the driver log path; [tales-driver] request: lines without a matching
response: identify the request that killed it. A common cause is a second
instrumentation starting: a device hosts one per package, and the new one kills
the old. Tales stops any previous driver before starting its own, so this should
only appear if something outside Tales started one.
Elements are invisible to id on a Compose screen — the app has not opted into
testTagsAsResourceId. See the locator note above.
upside_down orientation is not supported.adb forward; there is no USB-less
transport.