Skip to content

Mobile Android

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.

Terminal window
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
}
}
}
}
FieldMeaning
platform"android"
device_nameAVD name. Optional when serial is set.
serialadb serial (emulator-5554). Pins one device.
appPath to the .apk to install.
app_idApplication id (package name).
driver.externalConnect to a driver you started yourself.
driver.portHost port. Auto-allocated when omitted.
driver.adb_pathOverride adb discovery.
driver.timeoutDuration 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:

  1. An explicit serial must exist and be ready.
  2. Otherwise, if exactly one device is ready, it is used.
  3. Otherwise Tales lists the candidates and asks you to set 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.

LocatorAndroid source
idandroid:id resource id, or a Compose testTag
labelcontentDescription
textthe 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_buttonhome, back, recent_apps, power, volume_up, volume_down. lock is accepted as an alias for power.
set_orientationportrait, 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.
recordbit_rate and size; the simctl options (codec, mask, display) are rejected.
scroll_toScrolls 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.email" value = "[email protected]" }
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:

ServiceAndroid permissions
cameraCAMERA
microphoneRECORD_AUDIO
locationACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION
contactsREAD_CONTACTS + WRITE_CONTACTS
calendarREAD_CALENDAR + WRITE_CALENDAR
photosREAD_MEDIA_IMAGES + READ_MEDIA_VIDEO (API ≥ 33), else the storage pair
notificationsPOST_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.

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.

TargetNeeds
make e2e-androidadb + a device
make e2e-android-failureadb + a device
make doctor-androidnothing
make build-android-demoJDK 17 + SDK
make build-android-driverJDK 17 + SDK
make check-android-driver-freshJDK 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 doing

device.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.

  • No OCR, image matching, or XPath. Locators are id, label and text.
  • No cloud device farms.
  • upside_down orientation is not supported.
  • The driver speaks HTTP/JSON over an adb forward; there is no USB-less transport.