Cross-App Tests with UiAutomator — Drive System UI and Other Apps
UiAutomator is the only AndroidX test framework that can drive UI across process boundaries — Settings, the dialer, system notifications, another app's activity. Espresso refuses (InjectEventSecurityException) when an event would cross to a foreign window. UiAutomator works against the platform AccessibilityNodeInfo tree, so it sees every window. This skill encodes the modern BySelector / UiObject2 API, the singleton acquisition, the StaleObjectException recovery pattern, and the cross-app launch path.
When to use this skill
- The test must drive system UI (Settings, dialer, system notifications, quick settings, recents).
- The test spans the developer's app and another app (share sheet, contacts picker, camera).
- Espresso threw
InjectEventSecurityExceptionbecause the test reached a foreign window. - The user reports
StaleObjectExceptionand is debugging cached-node lifetime. - The user asks how to send the device home, press recents, or toggle airplane mode in a test.
- The user mixes
BySelectorandUiSelectorin the same test and is confused which is current.
When NOT to use this skill
- The UI is entirely inside the developer's app — Espresso (Views) or Compose tests are faster and more reliable. See
../../espresso/writing-espresso-tests/SKILL.mdfor Views. - The runner stack is not yet set up — start with
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - The user only needs to push device-side state changes (animations off, intent stub) — see
../../../adb/control/injecting-input-and-state/SKILL.md. - The test only needs to scrape logs after a run — see
../../../adb/observability/extracting-logs-with-logcat/SKILL.md.
Prerequisites
- Runner stack from
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md. androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0").- A real or virtual Android device with the
accessibility_serviceavailable (the default). - For
Context.startActivityto a foreign package on API 30+, a<queries>entry in the test APK manifest naming the target package or intent action.
Workflow
- 1. Acquire
UiDeviceonce per test, via the singleton with explicit instrumentation. The no-arggetInstance()is@Deprecated(R4 lines 56-58) — it throwsIllegalStateExceptionif not previously initialized:
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
private lateinit var device: UiDevice
@Before fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
}
- 2. Send hard keys and global actions via
UiDevicemethods. Source: R4 lines 88-104.
device.pressBack()
device.pressHome()
device.pressMenu()
device.pressRecentApps() // GLOBAL_ACTION_RECENTS
device.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.openNotification() // GLOBAL_ACTION_NOTIFICATIONS
device.openQuickSettings() // GLOBAL_ACTION_QUICK_SETTINGS
device.wakeUp() // power-on if off
device.sleep() // power-off if on
- 3. Find UI elements with
BySelector(modern) via theByfactory. Selectors are immutable-ish builders; the package-private constructor enforces use of the static factories. Criteria are joined as implicit AND. Source: R4 lines 185-258.
import androidx.test.uiautomator.By
By.text("Submit") // exact, case-sensitive
By.textContains("ubmi") // case-sensitive substring
By.textMatches("Sub.*") // Pattern.compile regex
By.desc("Open menu") // content description
By.res("com.example.app", "submit_btn") // package + res id
By.res("com.example.app:id/submit_btn") // qualified res id
By.clazz("android.widget.Button") // fully-qualified, or ".widget.Button" auto-prefixed with "android"
By.clazz(android.widget.Button::class.java)
By.pkg("com.android.settings")
By.depth(2) // depth from window root
By.checkable(true)
By.hasChild(By.text("Inner")) // sugar for hasDescendant(child, 1)
By.hasDescendant(By.text("anywhere"))
By.hasParent(By.clazz("...LinearLayout"))
By.hasAncestor(By.res("...container"))
- 4. Resolve to
UiObject2viadevice.findObject(...)and act.findObjectreturnsnullwhen no match (NEVER throws). For interactions, preferdevice.wait(Until.findObject(By...), timeoutMillis)so the lookup is robust to slow renders:
import androidx.test.uiautomator.Until
val submit = device.wait(Until.findObject(By.res("com.example", "submit")), 5_000)
?: error("Submit not found within 5s")
submit.click()
submit.longClick()
submit.setText("hello")
submit.clear()
submit.swipe(Direction.LEFT, 0.8f) // percent ∈ [0,1]
submit.scroll(Direction.DOWN, 1.0f)
submit.scrollUntil(Direction.DOWN, Until.findObject(By.text("Bottom")))
submit.pinchOpen(0.5f)
submit.pinchClose(0.5f)
UiObject2 caches an AccessibilityNodeInfo. Each action calls getAccessibilityNodeInfo() which calls device.waitForIdle() then mCachedNode.refresh(); on failure it runs registered UiWatchers, retries once, and on a second failure throws StaleObjectException (R4 lines 281-286, 622-637).
- 5. Wait for asynchronous UI states with
Untilconditions. Three condition flavors per R4 lines 495-540:
// SearchCondition (consume the device tree)
device.wait(Until.hasObject(By.text("Loaded")), 5_000)
device.wait(Until.gone(By.text("Loading")), 5_000)
val obj: UiObject2? = device.wait(Until.findObject(By.text("OK")), 5_000)
val list: List<UiObject2>? = device.wait(Until.findObjects(By.clazz("...Button")), 3_000)
// UiObject2Condition (consume a UiObject2 — for state predicates)
val checked = button.wait(Until.checked(true), 5_000)
// EventCondition (consume AccessibilityEvents — for window transitions)
val newWindowFired = device.performActionAndWait(
{ button.click() }, Until.newWindow(), 10_000,
)
- 6. Recover from
StaleObjectExceptionby re-finding.StaleObjectException extends RuntimeException— unchecked, must be handled when holding a long-livedUiObject2:
fun clickRetry(selector: BySelector, timeoutMillis: Long = 5_000) {
val obj = device.wait(Until.findObject(selector), timeoutMillis)
?: error("Not found: $selector")
try {
obj.click()
} catch (e: StaleObjectException) {
device.wait(Until.findObject(selector), timeoutMillis)?.click()
?: throw e
}
}
Better: re-look-up before each interaction instead of holding a long-lived reference. Common causes: activity transition, Compose recomposition replacing semantic nodes, RecyclerView recycle, configuration change (R4 lines 639-645).
- 7. Launch foreign packages — prefer
Context.startActivityoverexecuteShellCommand.executeShellCommandis@Discouragedper R4 lines 147-151 (UiDevice.java:1432); the Javadoc recommendsUiAutomation.executeShellCommandRwefor stdin/stderr. For routine app launches, use the in-process intent route plus a<queries>manifest entry on API 30+:
<!-- src/androidTest/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="com.android.settings" />
<intent>
<action android:name="android.settings.SETTINGS" />
</intent>
</queries>
<!-- ... -->
</manifest>
val ctx = ApplicationProvider.getApplicationContext<Context>()
ctx.startActivity(
Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
)
device.wait(Until.hasObject(By.pkg("com.android.settings").depth(0)), 10_000)
If the user really needs the shell route (e.g. a deep-link no Intent resolves):
device.executeShellCommand("am start -n com.android.settings/.Settings")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
- 8. Tune
Configuratorglobal timeouts when needed; restore on teardown. Settings are process-wide. Defaults (R4 lines 550-558): wait-for-idle 10s, wait-for-selector 10s (legacyUiObjectonly), action-acknowledgment 3s, scroll-event-wait 1s.
import androidx.test.uiautomator.Configurator
@Before fun setUp() {
val c = Configurator.getInstance()
originalIdle = c.waitForIdleTimeout
c.waitForIdleTimeout = 5_000 // tighter for fast tests
}
@After fun tearDown() {
Configurator.getInstance().waitForIdleTimeout = originalIdle
}
setKeyInjectionDelay is @Deprecated ("This parameter is no longer used (text is set directly rather than by key)." — R4 lines 575-577). Do not call it.
9. Recognize when to fall back to legacy
UiSelector/UiObject.UiSelectorre-resolves the node on every action viaConfigurator.getWaitForSelectorTimeout(), so it cannot go stale, but every call is more expensive and uses the checkedUiObjectNotFoundException. The modernBySelector/UiObject2is the recommended default. Use legacy only for interop with old test code or when re-resolution semantics are mandatory. R4 lines 594-619.10. Never call
findObject(By.text("..."))immediately after launch and assume it sees the target window.By.textmatches whatever is on screen now, which may be a transient splash or the previous app. Always combine withBy.pkg(...)or wait explicitly:
device.pressHome()
ctx.startActivity(Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000) // gate on package
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()
Patterns
Pattern: WRONG vs RIGHT — driving Settings without waiting on package transition
// WRONG
device.findObject(By.text("Settings")).click()
// WRONG because: at the moment of the call, By.text("Settings") matches whatever text reads
// "Settings" anywhere on the current screen — that might be a launcher tile, a notification,
// or a previous-app menu item. The test silently navigates to the wrong place.
// RIGHT
device.pressHome()
device.executeShellCommand("am start -a android.settings.SETTINGS")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()
Pattern: WRONG vs RIGHT — UiDevice.getInstance() no-arg
// WRONG
val device = UiDevice.getInstance()
// WRONG because: UiDevice.java:344 — the no-arg getInstance() is @Deprecated and throws
// IllegalStateException if the singleton has not been previously initialized with the
// instrumentation argument. Setting it implicitly via androidTest is unreliable across
// runner versions.
// RIGHT
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
Pattern: WRONG vs RIGHT — long-lived UiObject2 across a transition
// WRONG
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi")), 5_000)
device.openQuickSettings()
tile.click() // throws StaleObjectException — quick settings replaced the layout
// RIGHT — re-acquire after the transition
device.openQuickSettings()
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi").clickable(true)), 5_000)
tile?.click()
device.pressBack()
Pattern: dismissing all notifications
device.openNotification()
val shade = device.wait(
Until.findObject(By.res("com.android.systemui:id/notification_stack_scroller")),
5_000,
) ?: error("Notification shade not visible")
for (n in shade.findObjects(By.res("com.android.systemui:id/notification_layout"))) {
n.swipe(Direction.RIGHT, 1.0f)
}
device.pressBack()
Pattern: launching the app under test from a clean state
@Before fun launchAppFromHome() {
device.pressHome()
val ctx = ApplicationProvider.getApplicationContext<Context>()
val launch = ctx.packageManager.getLaunchIntentForPackage("com.example.app")!!
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
ctx.startActivity(launch)
device.wait(Until.hasObject(By.pkg("com.example.app").depth(0)), 5_000)
}
Mandatory rules
- MUST acquire
UiDeviceviaUiDevice.getInstance(InstrumentationRegistry.getInstrumentation()). The no-arggetInstance()is@Deprecatedand unreliable. - MUST prefer
BySelector/UiObject2overUiSelector/UiObjectfor new tests. Legacy is for interop only. - MUST wait on a transition with
device.wait(Until.hasObject(By.pkg("...")), timeout)before resolving inner elements after a cross-window change. BarefindObjectruns against the current window without gating. - MUST handle
StaleObjectException(re-find viadevice.wait(Until.findObject(By...), timeout)) when keeping aUiObject2reference across recompose, transition, or recyclerview recycle. - MUST restore
Configuratorsettings in@Afterif a test mutated them. Settings are process-wide and leak across tests. - MUST NOT call
Configurator.setKeyInjectionDelay—@Deprecatedand a no-op. - MUST NOT rely on
executeShellCommandfor routine app launches — it is@Discouraged. UseContext.startActivityplus a<queries>manifest entry on API 30+. - MUST NOT use
device.findObject(By.text("..."))without gating on package — text matches the current window contents, which may be a transient splash. - PREFERRED: wrap any single interaction in a
device.wait(Until.findObject(...), timeout)?.click()rather than callingfindObjectthenclicktwo lines apart. The two-step form races with rendering. - PREFERRED: add
<queries>for any foreign package the test launches — silent failures on API 30+ otherwise.
Verification
- No source file calls
UiDevice.getInstance()with no arguments. - No source file calls
Configurator.setKeyInjectionDelay. - Every long-lived
UiObject2reference has atry { ... } catch (StaleObjectException)around its action — or the test re-finds before each interaction. - Tests that launch foreign packages have a
<queries>entry for that package or its intent action insrc/androidTest/AndroidManifest.xml. -
./gradlew :<module>:connectedDebugAndroidTestruns withoutIllegalStateExceptionfromUiDevice.getInstance()and without unhandledStaleObjectExceptionpropagating to the test result. - No
findObject(By.text(...))is called immediately after a cross-window transition without a precedingdevice.wait(Until.hasObject(By.pkg(...)), ...). - If
Configuratorwas mutated in@Before, an@Afterrestores the original values.
References
- Android Developers — UiAutomator overview: https://developer.android.com/training/testing/other-components/ui-automator
- AndroidX Test (UiAutomator) release notes: https://developer.android.com/jetpack/androidx/releases/test
- UiAutomator API reference: https://developer.android.com/reference/androidx/test/uiautomator/package-summary
androidx/test/uiautomator/UiDevice.java— the singleton entry point. Lines 344 (@Deprecatedno-arggetInstance), 357 (currentgetInstance(Instrumentation)), 1432 (@Discouraged executeShellCommand).androidx/test/uiautomator/By.java— static factory forBySelector. Source fortext/desc/res/clazz/pkg/depth/checkable/hasChild/hasDescendant/hasParent/hasAncestor.androidx/test/uiautomator/BySelector.java(838 lines) — full criteria catalog. Lines 128 (auto-prefix.widget.Buttonwithandroid), 198/211/224 (regex / contains / starts-with conventions), 605-650 (maxDepth), 660 (displayId).androidx/test/uiautomator/UiObject2.java(1152 lines) —click,setText,swipe,scroll,scrollUntil,pinchOpen/Close,fling. Line 1031 —getAccessibilityNodeInfo()throwsStaleObjectExceptionafter refresh failure + watcher retry.androidx/test/uiautomator/Until.javalines 43-456 —hasObject,gone,findObject,findObjects,newWindow,scrollFinished, plusUiObject2Conditionpredicates.androidx/test/uiautomator/Configurator.javalines 36 (warning to restore), 108 (waitForSelectorlegacy-only), 163 (actionAcknowledgmentlegacy-only), 189 (setKeyInjectionDelay@Deprecated), 295 (setDefaultDisplayId).androidx/test/uiautomator/StaleObjectException.java—extends RuntimeException.tasks/research/R4-uiautomator.md— full UiAutomator 2.3.0 deep-dive. Lines 56-58 (deprecated singleton), 185-258 (BySelector catalog), 268-369 (UiObject2 actions), 495-540 (Untilconditions), 594-619 (modern vs legacy table), 622-662 (StaleObjectException recovery).docs/CORPUS.mdSection H.6 — UiAutomator essentials and the StaleObjectException recovery summary.