Skip to Content

Android

The driver layer for Android emulators and physical devices.

Android mirrors the iOS three-driver composite one-for-one. The runtime reports its Platform at registration; the composite reads that flag and wires the Android trio. Capabilities route to the driver that can fulfil them — same single fork point, same no-fall-through rule.

The three drivers

DriverMechanismOwns
Native@klera/native-driver-android — Expo Module, KotlindecorView MotionEvent injection (tap / swipe / multiTap / pinch / tapCoord), PixelCopy screenshot, in-process system UI
adb fallbackadb shell input / am / uiautomator dump, subprocessSame surface minus multi-touch and clipboard; for Expo Go hosts
Emulator-hostadb -s <serial> emu … + am + monkey, subprocesssetLocation / setBiometric / relaunch (with args and deep-link URL)

The composite is constructed exactly as on iOS; the only difference is the contents of each slot. Adopters never write the wiring — klera init and klera run resolve the platform and pick the right trio.

Native driver — primary

@klera/native-driver-android is an Expo Module written in Kotlin. Each DeviceAction lands on a method that touches the foreground activity’s decorView directly:

  • Tap / swipe / multi-touch synthesise MotionEvents (ACTION_DOWN / ACTION_MOVE / ACTION_UP, plus ACTION_POINTER_* for multi-finger gestures) and dispatch them through decorView.dispatchTouchEvent.
  • screenshot uses PixelCopy.request(window, bitmap, …) — reliable on hardware-accelerated views where the older View.draw(canvas) path produces black frames.
  • In-process system UI: setClipboard / getClipboard via ClipboardManager, setOrientation via Activity.requestedOrientation, dismissKeyboard via InputMethodManager.hideSoftInputFromWindow, openURL via Intent.ACTION_VIEW, backgroundApp / foregroundApp via the same intent surface.

Same speed advantage as the iOS native driver: no subprocess hop, no UiAutomator instrumentation, no shell round-trip.

adb fallback — Expo Go path

If the runtime registers without a linked native module, the composite falls back to a subprocess driver that shells out to adb. It covers:

  • swipeadb shell input swipe.
  • screenshotadb exec-out screencap -p (binary-safe stream).
  • openURLadb shell am start -a android.intent.action.VIEW -d <url>.
  • setOrientationadb shell settings put system user_rotation.
  • dismissKeyboard / pressBackadb shell input keyevent 4.
  • backgroundAppadb shell input keyevent 3 (KEYCODE_HOME).
  • dismissAlert / dismissActionSheetadb shell uiautomator dump to read the system-UI tree, find the matching button by text, tap its centre via adb shell input tap. Polls for ~2s because Android dialogs render asynchronously after the JS-side Alert.alert resolves.

Multi-touch (tapCoord / multiTap / pinch) and clipboard I/O are not in the adb subprocess surface — those reject with capability_unsupported so adopters get a clear error pointing at the dev-client + native-driver-android path.

Emulator-host driver — adb emu + am

Three capabilities can only be done from outside the app process:

  • setLocationadb -s <serial> emu geo fix <lon> <lat>.
  • setBiometricadb -s <serial> emu finger touch <id> for success / failure. The not-enrolled outcome rejects with capability_unsupported — the emulator console has no runtime hook to clear enrolments. Set hw.fingerprint = no in the AVD config instead.
  • relauncham force-stop <pkg> + monkey -p <pkg> -c android.intent.category.LAUNCHER 1, with an optional am start -a VIEW -d <url> deep link delivered after launch.

Android-only capabilities

Two capabilities exist on Android with no symmetric iOS affordance. iOS drivers reject both with capability_unsupported.

pressBack

Hardware-back gesture. The native driver dispatches a synthetic KeyEvent through decorView.dispatchKeyEvent:

val down = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK) val up = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK) activity.window.decorView.dispatchKeyEvent(down) activity.window.decorView.dispatchKeyEvent(up)

This routes through React Navigation / expo-router’s BackHandler chain — same path as a real user pressing the back button. The adb fallback uses adb shell input keyevent 4 for the same effect.

grantPermission

Bounded-enum runtime-permission grant — the in-process module rejects with capability_unsupported because the package-installer UI is a separate process the in-app driver cannot reach. The composite’s route map sends grantPermission straight to the emulator-host (or adb-fallback) driver:

adb -s <serial> shell pm grant <pkg> android.permission.<MAPPED>

The enum is bounded (camera / microphone / location / notifications) — adding a new permission is a single Zod-enum line in @klera/protocol, a mapping-table entry, and a flow that exercises it.

Setting up

Install the native driver

pnpm add -D @klera/runtime @klera/native-driver-android

Wrap your app root

// App.tsx import { KleraRuntimeProvider } from "@klera/runtime"; import KleraDriver from "@klera/native-driver-android"; export default function App() { return ( <KleraRuntimeProvider driver={KleraDriver}> <YourApp /> </KleraRuntimeProvider> ); }

The Expo Module’s app.plugin.js wires Gradle automatically.

Rebuild the dev client

npx expo prebuild --platform android npx expo run:android

Verify the wiring

pnpm exec klera doctor

klera doctor runs the iOS and Android trios side-by-side. Each row that matters for the platform you’re targeting needs a tick:

✓ Node ≥ 20 v20.x.x ✓ iOS Simulator 1 booted ✓ DeviceDriver native (iOS) linked + reachable ✓ DeviceDriver sim-host xcrun simctl reachable ✓ Android emulator emulator-5554 (online) ✓ DeviceDriver native (Android) linked + reachable ✓ DeviceDriver emulator-host adb reachable ✓ New Architecture Bridgeless detected

A worked native-driver flow

# flows/android-native-driver.yaml name: Android multi-touch + back-button + permission grant steps: - waitForIdle: { animations: true, network: true } # Multi-touch — native-only, in-process. - pinch: from: [[140, 380], [240, 380]] to: [[60, 380], [320, 380]] durationMs: 400 - assert: { visible: Zoomed in } # Hardware back — Android-only. - pressBack - assert: { visible: Settings } # Runtime permission — bounded enum, routed to the # emulator-host driver via `adb shell pm grant`. - grantPermission: { permission: camera } - tap: Take photo - assert: { visible: Camera preview } # Deep link via emulator-host relaunch. - relaunch: url: "expodemo://profile/42" - assert: { visible: Profile · 42 } # In-process clipboard read. - setClipboard: hello-android - tap: Paste - assert: { visible: hello-android }

klera run flows/android-native-driver.yaml --target android resolves the trio at run time, dispatches pinch and pressBack to the native module, routes grantPermission and relaunch to the emulator-host, and lands setClipboard back in the native module. Adopter does not think about which driver gets which call.

Common gotchas

emulator-5554 not detected. adb devices should list at least one row in the device state (not offline, not unauthorized). If it’s empty, adb kill-server && adb start-server re-discovers attached emulators. If you’re connecting over Wi-Fi, adb connect <ip>:5555 first.

Multiple devices attached. The adb and emulator-host drivers default to the first device-state row in adb devices. Pin a specific serial in .klera/config.yaml:

drivers: android: serial: emulator-5554

Android 13+ permission model. POST_NOTIFICATIONS is the runtime-permission gate adopters miss most often — Android 13 made notification posting opt-in. If your flow asserts on a notification banner, add grantPermission: { permission: notifications } before the trigger. The bounded enum maps it to android.permission.POST_NOTIFICATIONS under the hood.

uiautomator dump on Android 14+. Older docs use uiautomator dump /dev/tty to stream the XML through stdout. That recipe broke on Android 14 — the binary now writes only the status line to /dev/tty. The adb driver uses the supported “dump to file, cat the file” pattern (/sdcard/klera-ui.xml) which works across every Android version klera targets.

Material AlertDialog uppercase labels. Android renders dialog buttons with textAllCaps — JSX "Sign out" shows up as SIGN OUT in the uiautomator dump. The adb driver tries exact match first, then falls back to case-insensitive, so flows authored with the JSX casing still find the button.

SDK platform-tools not on PATH. klera doctor reports the exact install hint: brew install android-platform-tools (or grab them via Android Studio’s SDK Manager).

Capability matrix

ActionNativeadbEmulator-host
swipe
tapCoord✗ (screen-coord vs view-coord)
multiTap
pinch
screenshot✓ (PixelCopy)✓ (screencap -p)
setClipboard / getClipboard✗ (API 29+ permission gate)
setOrientation
dismissAlert / dismissActionSheet
dismissKeyboard
backgroundApp / foregroundApp
openURL
pressBack
grantPermission✗ (out-of-process)✓ (routed)✓ (routed)
setLocation✓ (routed)
setBiometric✓ (routed, except not-enrolled)
relaunch✓ (routed)

Next steps

Last updated on