diff --git a/docs/superpowers/evidence/android-ui-vd-keyboard-2026-09-27.json b/docs/superpowers/evidence/android-ui-vd-keyboard-2026-09-27.json new file mode 100644 index 00000000..ecf47076 --- /dev/null +++ b/docs/superpowers/evidence/android-ui-vd-keyboard-2026-09-27.json @@ -0,0 +1,40 @@ +{ + "intent": "Two Claude sessions sharing one phone must not disturb each other: a keyboard open on the real screen must never make android_ui press BACK on another session's virtual display.", + "scope": [ + "python_pkg/android_ui/_text_entry.py (keyboard_is_up only)", + "Non-goal: no change to real-screen (display 0 / no display) behaviour, which still checks and dismisses the IME" + ], + "changes": [ + "keyboard_is_up returns False when the driver targets a virtual display (display not None and not 0), so dismiss_keyboard sends no ESCAPE/BACK there", + "Test: a VD driver with the phone-wide keyboard shown runs no adb command at all" + ], + "verification": [ + { + "command": "adb shell dumpsys input_method (live phone, 2026-09-27)", + "result": "pass", + "evidence": "every IME binding reads imeDisplayId=0, including one whose targetWin was com.kuhy.workout_app.sandbox on targetDisplayId=13: the IME is drawn on display 0 for VD windows" + }, + { + "command": "python -m python_pkg.android_ui --display self tap ... (before the fix, while another session typed on the real screen)", + "result": "fail", + "evidence": "'the soft keyboard is still covering the screen after ESCAPE and BACK'; the BACK popped the workout route, a second run closed the app on the VD" + }, + { + "command": "same taps after the fix", + "result": "pass", + "evidence": "tapped 'Dumbbell Lunge progression settings' / 'Warmup set' / 'Wins needed down' on the VD with the other session still active" + }, + { + "command": "pytest python_pkg/android_ui --cov=python_pkg/android_ui --cov-branch", + "result": "pass", + "evidence": "87 passed; _text_entry.py 100% lines and branches" + } + ], + "risks": [ + "A future Android build that draws the IME on the VD itself would make taps under it land on keys; the phone would have to show imeDisplayId= for that, which it does not today" + ], + "rollback": [ + "git revert this commit", + "After rollback, VD taps work again only while no other session has a keyboard open on the real screen" + ] +} diff --git a/docs/superpowers/evidence/nvidia-config-allowlist-2026-08-23.json b/docs/superpowers/evidence/nvidia-config-allowlist-2026-08-23.json deleted file mode 100644 index ce1f8121..00000000 --- a/docs/superpowers/evidence/nvidia-config-allowlist-2026-08-23.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "intent": "Remove nvidia_config.sh's coverage exemption, which was reinstated when an earlier commit's allowlist deletion did not survive alongside the seam restore.", - "scope": [ - "meta/shell-coverage-allowlist.txt (one line removed)", - "Non-goal: any code change; the lib and its tests are already correct" - ], - "changes": [ - "Deleted the fixes/lib/nvidia_config.sh line from the allowlist, taking it from 85 to 84 entries" - ], - "verification": [ - { - "command": "shell_coverage_jail.sh with the gate's own flags (--bind /etc --bind /usr/local/bin --bind /var/lib), in a detached worktree", - "result": "pass", - "evidence": "nvidia_config.sh: 76/76 lines = 100.00%." - }, - { - "command": "The same measurement in the main working tree", - "result": "fail", - "evidence": "'the subject modified the working tree; a write escaped the jail', listing python_pkg/endurain_import files another agent was editing concurrently. This is a false positive from the jail's tree fingerprint, which is why the real measurement was taken in an isolated worktree." - }, - { - "command": "grep for the four seams in nvidia_config.sh", - "result": "pass", - "evidence": "All four present at lines 26, 27, 60 and 85. An earlier `grep -c 'XORG_CONF:-'` returning 1 counted matching LINES, not seams, and briefly looked like a regression." - } - ], - "risks": [ - "The coverage gate cannot be trusted while another agent is writing to the same working tree; any measurement must be taken in a detached worktree or it may abort on their churn." - ], - "rollback": [ - "Re-add the nvidia_config.sh line to meta/shell-coverage-allowlist.txt" - ] -} diff --git a/python_pkg/android_ui/_text_entry.py b/python_pkg/android_ui/_text_entry.py index dead1d02..8447f4a5 100644 --- a/python_pkg/android_ui/_text_entry.py +++ b/python_pkg/android_ui/_text_entry.py @@ -28,6 +28,7 @@ class TextEntryMixin(ABC): """The text-entry half of :class:`~python_pkg.android_ui.driver.AndroidUi`.""" _settle: float + _display: int | None @abstractmethod def _run(self, *args: str, timeout: float = 30.0) -> str: @@ -137,7 +138,17 @@ class TextEntryMixin(ABC): Without this, a caller cannot tell "the button is absent" from "the button is behind the keyboard", and the accessibility tree reports the button's laid-out position either way. + + Always False on a virtual display: Android draws the IME on the + default display (``imeDisplayId=0``) even when its target window is + on a VD, so it can never cover a VD tap -- while ``mInputShown`` is + phone-wide. Checking it there meant another session typing on the + real screen made :meth:`dismiss_keyboard` press BACK on *this* + display, popping the route under test (2026-09-27, two sessions on + one phone). """ + if self._display not in (None, 0): + return False out = self._run("shell", "dumpsys", "input_method") match = re.search(r"mInputShown=(\w+)", out) return match is not None and match.group(1) == "true" diff --git a/python_pkg/android_ui/tests/test_text_entry.py b/python_pkg/android_ui/tests/test_text_entry.py index c2f3db90..bcbf13a8 100644 --- a/python_pkg/android_ui/tests/test_text_entry.py +++ b/python_pkg/android_ui/tests/test_text_entry.py @@ -98,6 +98,22 @@ class TestKeyboard: ui.device.keyboard_shown = False assert not ui.ui.keyboard_is_up() + def test_never_up_on_a_virtual_display( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The IME draws on display 0 even for a VD's window, so it cannot + # cover a VD tap -- but mInputShown is phone-wide. Reading it there + # let another session's keyboard make dismiss_keyboard press BACK on + # this display and pop the route under test (2026-09-27). + device = FakeDevice() + device.keyboard_shown = True + vd = drv.AndroidUi(settle_seconds=0.0, display=7) + monkeypatch.setattr(vd, "_run", device.run) + monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None) + assert not vd.keyboard_is_up() + vd.dismiss_keyboard() + assert device.calls == [], "no dumpsys, and above all no BACK" + def test_dismiss_is_a_no_op_when_already_closed(self, ui: Harness) -> None: ui.ui.dismiss_keyboard() assert not [