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
| Driver | Mechanism | Owns |
|---|---|---|
| Native | @klera/native-driver-android — Expo Module, Kotlin | decorView MotionEvent injection (tap / swipe / multiTap / pinch / tapCoord), PixelCopy screenshot, in-process system UI |
| adb fallback | adb shell input / am / uiautomator dump, subprocess | Same surface minus multi-touch and clipboard; for Expo Go hosts |
| Emulator-host | adb -s <serial> emu … + am + monkey, subprocess | setLocation / 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, plusACTION_POINTER_*for multi-finger gestures) and dispatch them throughdecorView.dispatchTouchEvent. screenshotusesPixelCopy.request(window, bitmap, …)— reliable on hardware-accelerated views where the olderView.draw(canvas)path produces black frames.- In-process system UI:
setClipboard/getClipboardviaClipboardManager,setOrientationviaActivity.requestedOrientation,dismissKeyboardviaInputMethodManager.hideSoftInputFromWindow,openURLviaIntent.ACTION_VIEW,backgroundApp/foregroundAppvia 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:
swipe→adb shell input swipe.screenshot→adb exec-out screencap -p(binary-safe stream).openURL→adb shell am start -a android.intent.action.VIEW -d <url>.setOrientation→adb shell settings put system user_rotation.dismissKeyboard/pressBack→adb shell input keyevent 4.backgroundApp→adb shell input keyevent 3(KEYCODE_HOME).dismissAlert/dismissActionSheet→adb shell uiautomator dumpto read the system-UI tree, find the matching button by text, tap its centre viaadb shell input tap. Polls for ~2s because Android dialogs render asynchronously after the JS-sideAlert.alertresolves.
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:
setLocation→adb -s <serial> emu geo fix <lon> <lat>.setBiometric→adb -s <serial> emu finger touch <id>forsuccess/failure. Thenot-enrolledoutcome rejects withcapability_unsupported— the emulator console has no runtime hook to clear enrolments. Sethw.fingerprint = noin the AVD config instead.relaunch→am force-stop <pkg>+monkey -p <pkg> -c android.intent.category.LAUNCHER 1, with an optionalam 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-androidWrap 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:androidVerify the wiring
pnpm exec klera doctorklera 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 detectedA 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-5554Android 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
| Action | Native | adb | Emulator-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) |