Files
Michał Pierzchała c7f42ccedc refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports

* fix: address Android W5 review feedback

* fix: update relocated routing fixture assertion
2026-08-28 13:02:28 +02:00
..

Android Test IME Helper

A minimal headless InputMethodService used as a deterministic, Unicode-safe text-entry backend for local Android sessions. onEvaluateInputViewShown() returns false and onCreateInputView() returns null, so activating this IME contributes zero accessibility nodes to the UI tree -- no per-key [group]+[text] chrome, no clipboard/translate/voice-dictation buttons -- unlike the real system keyboard (Gboard et al.), which pulls its visible keyboard into the UIAutomator tree the moment a field is focused.

Text is injected through a dynamically-registered BroadcastReceiver carrying base64-encoded UTF-8 Intent extras, the same payloadBase64 convention the snapshot helper's gesture mode uses for the same reason: adb shell re-tokenizes raw spaces, and base64 sidesteps that entirely. This makes Unicode/CJK/emoji round-trip exactly, unlike adb shell input text, which is ASCII-only.

The helper is a service, not an instrumentation, so it cannot use the snapshot helper's am instrument invocation shape. It is installed and its version verified the same way (bundled npm-packaged dist + version-keyed manifest.json), but invoked with adb shell ime enable/ime set (lifecycle) and adb shell am broadcast (text entry) instead.

Build

VERSION="$(node -p 'require("./package.json").version')"
AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/build-android-helper.sh "$VERSION" .tmp/android-ime-helper

Run

PACKAGE="com.callstack.agentdevice.imehelper"
SERVICE="$PACKAGE/.TestInputMethodService"
VERSION="$(node -p 'require("./package.json").version')"

adb install -r -t ".tmp/android-ime-helper/agent-device-android-ime-helper-$VERSION.apk"

# Record the current default IME before switching, so it can be restored exactly.
PREVIOUS_IME="$(adb shell settings get secure default_input_method)"

adb shell ime enable "$SERVICE"
adb shell ime set "$SERVICE"

# Focus a text field, then inject Unicode-safe text via base64 extras. adb shell holds
# WRITE_SECURE_SETTINGS, which the receiver requires; a third-party app cannot.
TEXT_B64="$(printf '%s' '你好世界' | base64)"
adb shell am broadcast -p "$PACKAGE" \
  -a com.callstack.agentdevice.imehelper.ACTION_INPUT_TEXT_B64 \
  --es text "$TEXT_B64"

# Clear the focused field.
adb shell am broadcast -p "$PACKAGE" -a com.callstack.agentdevice.imehelper.ACTION_CLEAR_TEXT

# Restore the previous IME (critical -- do not skip on a real device).
adb shell ime set "$PREVIOUS_IME"

Broadcast actions

The receiver is registered in the running IME process (so getCurrentInputConnection() is live) but requires the android.permission.WRITE_SECURE_SETTINGS sender permission. adb shell (uid 2000) holds that signature|privileged permission, but a co-installed third-party app cannot be granted it, so it cannot deliver broadcasts to the receiver — the text-injection surface is closed to other apps while the IME is active. (An earlier design used a manifest receiver with android:exported="false" + explicit-component targeting, but on API 36 adb shell cannot deliver to a non-exported component, which broke the CLI path; the permission gate is what actually works.) Every extra is still treated as untrusted input: bounded length, defensive base64 decoding, and the whole handler is wrapped so a malformed broadcast can never crash the IME process and strand a field without an active input method.

  • ACTION_INPUT_TEXT_B64 (--es text <base64 utf-8>) -- commit decoded text at the cursor.
  • ACTION_INPUT_TEXT (--es text <string>) -- commit text directly (subject to adb shell's own tokenization; prefer the base64 variant for anything with spaces or non-ASCII).
  • ACTION_CLEAR_TEXT -- select-all and commit an empty string.

An optional --es protocol android-ime-helper-v1 extra is a defensive sanity check (not a security boundary): if present and it doesn't match, the broadcast is dropped and logged.

Restore hygiene

Switching the active IME is machine-global, not session-scoped. A crashed daemon that switched the IME and never restored it leaves the device with an invisible keyboard -- on a real device, this means the user cannot type anywhere until they manually reset their IME in Settings. The TS-side lifecycle (packages/platform-android/src/ime-lifecycle.ts) persists the previously active IME to disk before switching, restores it on session close/teardown, and best-effort restores any orphaned switch left behind by a previous crashed daemon on startup.