Files
callstack__agent-device/scripts/package-android-ime-helper.sh
Michał Pierzchała d4146c7f1b feat: add Android test IME helper for deterministic text entry (#1198) (#1201)
* feat: add Android test IME helper for deterministic text entry (#1198)

Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.

- android-ime-helper/: InputMethodService + build/package scripts on the
  existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
  lifecycle (shared with the other two helpers via the new
  helper-package-install.ts), activation on session open, and on-device
  restore-hygiene (previous IME persisted to a device settings key so any
  daemon/state-dir can recover it; restored on close, daemon teardown, and
  daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
  when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
  copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
  devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
  now-provably-live skillgym non-ASCII eval case, updated the ASCII
  fallback's error message to point at the helper instead of dead-ending.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out

Addresses the independent review's blockers and should-fixes.

SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.

CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.

Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(#1201): pin the API-36 exported-receiver constraint in a comment

The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)

Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.

Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.

Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams

Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:

- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
  + its test, Java handler, README): nothing routes through it — `keyboard enter`
  uses the keyevent ENTER path — so the new production-exports gate flagged it as
  dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
  resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
  fallow-baselines/production-unused-exports.json, matching how the sibling helper
  reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
  grandfathered there.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)

Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.

Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.

Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): suppress fallow class-member false-positive on state-dir accessor

CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): durable persist before switch + device-scoped recovery markers

Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.

P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.

P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 19:38:37 +02:00

64 lines
2.0 KiB
Bash
Executable File

#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <version> <output-dir>" >&2
exit 1
fi
VERSION="$1"
OUTPUT_DIR="$2"
PROJECT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
PACKAGE_NAME="com.callstack.agentdevice.imehelper"
SERVICE_COMPONENT="$PACKAGE_NAME/.TestInputMethodService"
APK_BASENAME="agent-device-android-ime-helper-$VERSION.apk"
CHECKSUM_BASENAME="$APK_BASENAME.sha256"
MANIFEST_BASENAME="agent-device-android-ime-helper-$VERSION.manifest.json"
write_github_output() {
if [ -n "${GITHUB_OUTPUT:-}" ]; then
printf '%s\n' "$1" >> "$GITHUB_OUTPUT"
fi
}
mkdir -p "$OUTPUT_DIR"
BUILD_OUTPUT="$(sh "$PROJECT_DIR/scripts/build-android-ime-helper.sh" "$VERSION" "$OUTPUT_DIR")"
APK_PATH="$(printf '%s\n' "$BUILD_OUTPUT" | awk -F= '$1 == "apk" { print $2 }')"
VERSION_CODE="$(printf '%s\n' "$BUILD_OUTPUT" | awk -F= '$1 == "version_code" { print $2 }')"
CHECKSUM_PATH="$OUTPUT_DIR/$CHECKSUM_BASENAME"
MANIFEST_PATH="$OUTPUT_DIR/$MANIFEST_BASENAME"
if [ ! -f "$APK_PATH" ]; then
echo "Helper APK was not created at $APK_PATH" >&2
exit 1
fi
SHA256="$(shasum -a 256 "$APK_PATH" | awk '{print $1}')"
printf '%s %s\n' "$SHA256" "$APK_BASENAME" > "$CHECKSUM_PATH"
{
printf '{\n'
printf ' "name": "android-ime-helper",\n'
printf ' "version": "%s",\n' "$VERSION"
printf ' "assetName": "%s",\n' "$APK_BASENAME"
printf ' "sha256": "%s",\n' "$SHA256"
printf ' "packageName": "%s",\n' "$PACKAGE_NAME"
printf ' "versionCode": %s,\n' "$VERSION_CODE"
printf ' "serviceComponent": "%s",\n' "$SERVICE_COMPONENT"
printf ' "broadcastProtocol": "android-ime-helper-v1"\n'
printf '}\n'
} > "$MANIFEST_PATH"
write_github_output "apk_path=$APK_PATH"
write_github_output "checksum_path=$CHECKSUM_PATH"
write_github_output "manifest_path=$MANIFEST_PATH"
write_github_output "apk_name=$APK_BASENAME"
write_github_output "sha256=$SHA256"
write_github_output "package_name=$PACKAGE_NAME"
write_github_output "version_code=$VERSION_CODE"
printf 'apk=%s\n' "$APK_PATH"
printf 'checksum=%s\n' "$CHECKSUM_PATH"
printf 'manifest=%s\n' "$MANIFEST_PATH"