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 toadb 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.
Private field comparison uses content write with a permission-gated provider at
content://com.callstack.agentdevice.imehelper.private/request/<random-id>.
The JSON request travels over stdin, never command arguments or disk. The provider
accepts at most 64 KiB, closes an unfinished pipe after two seconds, and expires
unconsumed requests after five seconds. ACTION_PRIVATE_INPUT consumes the random
request ID once and returns only a comparison status and bounded provenance.
The android-private-input-v1 protocol first acquires an input-connection token,
then compares against the same app and service-instance/input-generation token.
The original snapshot brackets its native capture with that token and retains
the token plus native window ID privately on its focused node. Comparison requires
the original token, a fresh complete capture of the same focused target and native
window, and another token fence after comparison. This private provenance is omitted
from JSON output and cannot survive a daemon restart. Null, partial, oversized,
fully or partially masked, timed-out, or changed-connection
readings return unknown. Neither dispatched text nor accessibility masks prove
equality. The IME's pure comparison tests run as part of its normal build.
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.