diff --git a/.claude/skills/create-verifier/SKILL.md b/.claude/skills/create-verifier/SKILL.md index cffdc48..55f5d80 100644 --- a/.claude/skills/create-verifier/SKILL.md +++ b/.claude/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.claude/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.claude/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.claude/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.claude/skills/verify-openloop/features/INVENTORY.md b/.claude/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.claude/skills/verify-openloop/features/INVENTORY.md +++ b/.claude/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) | diff --git a/.claude/skills/verify-openloop/features/photo-capture.md b/.claude/skills/verify-openloop/features/photo-capture.md index e98b047..4ad208c 100644 --- a/.claude/skills/verify-openloop/features/photo-capture.md +++ b/.claude/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .claude/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.claude/skills/verify-openloop/helpers/photo_mode_loop.py b/.claude/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..7d2cf03 --- /dev/null +++ b/.claude/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .claude/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.claude/skills/verify-openloop/helpers/verify_common.py b/.claude/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.claude/skills/verify-openloop/helpers/verify_common.py +++ b/.claude/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes diff --git a/.codex/skills/create-verifier/SKILL.md b/.codex/skills/create-verifier/SKILL.md index 4251a32..bd14c6c 100644 --- a/.codex/skills/create-verifier/SKILL.md +++ b/.codex/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.codex/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.codex/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.codex/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.codex/skills/verify-openloop/features/INVENTORY.md b/.codex/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.codex/skills/verify-openloop/features/INVENTORY.md +++ b/.codex/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) | diff --git a/.codex/skills/verify-openloop/features/photo-capture.md b/.codex/skills/verify-openloop/features/photo-capture.md index e98b047..b94f764 100644 --- a/.codex/skills/verify-openloop/features/photo-capture.md +++ b/.codex/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .codex/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.codex/skills/verify-openloop/helpers/photo_mode_loop.py b/.codex/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..02bb2e2 --- /dev/null +++ b/.codex/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .codex/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.codex/skills/verify-openloop/helpers/verify_common.py b/.codex/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.codex/skills/verify-openloop/helpers/verify_common.py +++ b/.codex/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes diff --git a/.cursor/skills/create-verifier/SKILL.md b/.cursor/skills/create-verifier/SKILL.md index d85a7f5..0f08d27 100644 --- a/.cursor/skills/create-verifier/SKILL.md +++ b/.cursor/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.cursor/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.cursor/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.cursor/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.cursor/skills/verify-openloop/features/INVENTORY.md b/.cursor/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.cursor/skills/verify-openloop/features/INVENTORY.md +++ b/.cursor/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) | diff --git a/.cursor/skills/verify-openloop/features/photo-capture.md b/.cursor/skills/verify-openloop/features/photo-capture.md index e98b047..e96be30 100644 --- a/.cursor/skills/verify-openloop/features/photo-capture.md +++ b/.cursor/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .cursor/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py b/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..8c5a69b --- /dev/null +++ b/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .cursor/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.cursor/skills/verify-openloop/helpers/verify_common.py b/.cursor/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.cursor/skills/verify-openloop/helpers/verify_common.py +++ b/.cursor/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes