Skip to content

feat(keyboard-guard): in-runner keyboard-occlusion guard for live device_* taps, iOS + Android (#370)#380

Merged
Lykhoyda merged 13 commits into
mainfrom
feat/370-keyboard-guard-phase2a
Jul 1, 2026
Merged

feat(keyboard-guard): in-runner keyboard-occlusion guard for live device_* taps, iOS + Android (#370)#380
Lykhoyda merged 13 commits into
mainfrom
feat/370-keyboard-guard-phase2a

Conversation

@Lykhoyda

@Lykhoyda Lykhoyda commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Closes #370 (Phase 2 of #356; Phase 1 was PR #367).

What

Adds a default-ON, in-runner keyboard-occlusion guard for live device_press/device_longpress taps on iOS and Android: before a guarded coordinate tap, the runner checks whether a visible software keyboard's sane rect (non-empty, ≥120pt iOS / ≥150px Android — accessory bars excluded) contains the tap point, and auto-dismisses only when genuinely occluded (KeyboardAvoidingView-lifted controls are left alone).

  • Android — dismisses via pressBack + bounded waitForIdle(1500), gated on a real TYPE_INPUT_METHOD window with sane bounds (destructive-back safe; device-verified route-unchanged). Requires FLAG_RETRIEVE_INTERACTIVE_WINDOWS, enabled at dispatcher init (flag-only mutation, null-guarded) — without it getWindows() is silently empty (the B223 no-op class the plan review flagged).
  • iOSverify-or-refuse: only a genuine dismiss control ("Hide keyboard"/"Done") is tapped and the result re-verified; standard iPhone QWERTY (no such control) refuses with KEYBOARD_OCCLUDED … keyboardGuard=dismiss_failed rather than tapping the keyboard. Device-proven rationale: XCTest swipeDown() on the keyboard triggers QuickPath slide-typing and corrupts the focused field.
  • Telemetry — every guarded gesture surfaces meta.keyboardGuard: off | no_keyboard | not_occluded | dismissed (+ dismiss_failed in the refusal error).
  • Opt-outRN_KEYBOARD_GUARD=0, resolved TS-side per command (guardKeyboard on the wire; absent → guard ON, with an iOS Codable decode regression test guarding exactly that).
  • Scope — command-handler tap/press + longPress only; tapSeries, by-text taps, type/fill focus-taps, swipes, doubleTap deliberately unguarded.

Note: the plan's 2a (TS+iOS) / 2b (Android) stacked-PR split was collapsed into this single branch — the tasks interleaved naturally and each platform was device-verified independently.

Verification

  • Unit: 2548/2548 TS (node --test), 6/6 XCTest (incl. the var-not-let decode regression), 6/6 JVM Kotlin (incl. exclusive-until boundary pins); oxlint + oxfmt clean; committed dist/ confirmed byte-identical to a fresh rebuild.
  • Device pass (both platforms, proofs in rn-dev-agent-workspace/docs/proof/370-keyboard-occlusion-phase2/):
    • iOS (iPhone 16 Pro): no_keyboard ✓, not_occluded ✓ (real keyboard frame; high tap spared), KEYBOARD_OCCLUDED refusal ✓ side-effect-free (keyboard stays, field unchanged). Live-measured KAV case: the wizard's Next button lifts y=790→571 and half-overlaps the keyboard — the refusal correctly prevents a post-dismiss miss-tap.
    • Android (Pixel 9 Pro, API 36): flag efficacy proven (not_occluded requires window visibility), no_keyboard/not_occluded end-to-end with meta.keyboardGuard ✓, dismissed ✓ (timed 3.6s incl. bounded idle; mInputShown true→false), destructive-back safety ✓ (route unchanged across all low taps).
  • Device verification caught and fixed two real defects mid-branch: the iOS guard originally reported "dismissed" without checking the result while swipeDown QuickPath-corrupted the field (fixed: verify-or-refuse, ebb35874), and Android's unbounded waitForIdle blew the client timeout (fixed: bounded 1500ms, 321b2970).

Process

Multi-LLM-reviewed plan (6 blockers fixed pre-code) → subagent-driven TDD (spec + quality review gate per task) → consolidated device pass → final whole-branch review (verdict: ready to merge; all deferred minors triaged, follow-ups filed).

Follow-ups (filed)

🤖 Generated with Claude Code

Lykhoyda and others added 13 commits July 1, 2026 17:44
…iewed + amended)

TDD plan for the in-runner live-tap keyboard-occlusion guard (iOS + Android),
amended per the Claude+Codex plan review: pure predicate in a dependency-free
file, guard at the command handler (not tapAt), Android FLAG_RETRIEVE_INTERACTIVE_WINDOWS,
containment predicate, destructive-back safety, off-device tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ns for KeyboardGuard (#370)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ead of lying (#370)

applyKeyboardGuard reported 'dismissed' without checking the result, and
dismissKeyboard's swipeDown corrupts the focused field via QuickPath
slide-typing on iPhone QWERTY. Guard path now uses only the safe
dismiss-control tap, re-verifies visibility, and returns dismiss_failed;
the command handler refuses the tap with KEYBOARD_OCCLUDED instead of
tapping the keyboard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2: in-runner keyboard-occlusion guard for live device_* taps (#356 follow-on)

1 participant