| name | cross-app-tests-with-uiautomator |
| description | Use this skill to drive cross-app and system-UI flows from instrumentation tests using UiAutomator 2.3.0 — `UiDevice`, `BySelector` / `UiObject2` (modern), `UiSelector` / `UiObject` (legacy), `Until` conditions, and `Configurator` global timeouts. Covers the singleton acquisition (`UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())`), `pressBack/pressHome/pressRecentApps`, `device.findObject(By...)` cached-node refresh and `StaleObjectException` recovery, the `executeShellCommand` `@Discouraged` path vs `Context.startActivity` plus `<queries>`, and the full `By` factory catalog. Use when the user reports `StaleObjectException`, `Espresso InjectEventSecurityException`, `findObject returns null`, asks "how do I open Settings from a test", "toggle Wi-Fi", "dismiss notification shade", or "test that spans my app and another". |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["uiautomator","UiDevice","BySelector","UiObject2","UiSelector","Until","Configurator","StaleObjectException","cross-app-test","system-ui-test","pressHome","executeShellCommand"]} |
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
InjectEventSecurityException because the test reached a foreign window.
- The user reports
StaleObjectException and 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
BySelector and UiSelector in 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.md for 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_service available (the default).
- For
Context.startActivity to a foreign package on API 30+, a <queries> entry in the test APK manifest naming the target package or intent action.
Workflow
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
private lateinit var device: UiDevice
@Before fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
}
device.pressBack()
device.pressHome()
device.pressMenu()
device.pressRecentApps()
device.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.openNotification()
device.openQuickSettings()
device.wakeUp()
device.sleep()
import androidx.test.uiautomator.By
By.text("Submit")
By.textContains("ubmi")
By.textMatches("Sub.*")
By.desc("Open menu")
By.res("com.example.app", "submit_btn")
By.res("com.example.app:id/submit_btn")
By.clazz("android.widget.Button")
By.clazz(android.widget.Button::class.java)
By.pkg("com.android.settings")
By.depth(2)
By.checkable(true)
By.hasChild(By.text("Inner"))
By.hasDescendant(By.text("anywhere"))
By.hasParent(By.clazz("...LinearLayout"))
By.hasAncestor(By.res("...container"))
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)
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).
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)
val checked = button.wait(Until.checked(true), 5_000)
val newWindowFired = device.performActionAndWait(
{ button.click() }, Until.newWindow(), 10_000,
)
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).
<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)
import androidx.test.uiautomator.Configurator
@Before fun setUp() {
val c = Configurator.getInstance()
originalIdle = c.waitForIdleTimeout
c.waitForIdleTimeout = 5_000
}
@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.
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)
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()
Patterns
Pattern: WRONG vs RIGHT — driving Settings without waiting on package transition
device.findObject(By.text("Settings")).click()
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
val device = UiDevice.getInstance()
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
Pattern: WRONG vs RIGHT — long-lived UiObject2 across a transition
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi")), 5_000)
device.openQuickSettings()
tile.click()
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
UiDevice via UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()). The no-arg getInstance() is @Deprecated and unreliable.
- MUST prefer
BySelector / UiObject2 over UiSelector / UiObject for 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. Bare findObject runs against the current window without gating.
- MUST handle
StaleObjectException (re-find via device.wait(Until.findObject(By...), timeout)) when keeping a UiObject2 reference across recompose, transition, or recyclerview recycle.
- MUST restore
Configurator settings in @After if a test mutated them. Settings are process-wide and leak across tests.
- MUST NOT call
Configurator.setKeyInjectionDelay — @Deprecated and a no-op.
- MUST NOT rely on
executeShellCommand for routine app launches — it is @Discouraged. Use Context.startActivity plus 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 calling findObject then click two 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
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 (@Deprecated no-arg getInstance), 357 (current getInstance(Instrumentation)), 1432 (@Discouraged executeShellCommand).
androidx/test/uiautomator/By.java — static factory for BySelector. Source for text/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.Button with android), 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() throws StaleObjectException after refresh failure + watcher retry.
androidx/test/uiautomator/Until.java lines 43-456 — hasObject, gone, findObject, findObjects, newWindow, scrollFinished, plus UiObject2Condition predicates.
androidx/test/uiautomator/Configurator.java lines 36 (warning to restore), 108 (waitForSelector legacy-only), 163 (actionAcknowledgment legacy-only), 189 (setKeyInjectionDelay @Deprecated), 295 ().