Repository navigation
Android native-a11y → snapshot: conform to the acquire/present engine contracts (sibling of #1797) #1832
Description
Context
#1797 (iOS acquire/present split, hardened through three internal adversarial reviews and four external review passes) produced six platform-agnostic Snapshot Engine Contracts. This issue is the Android conformance audit against them and the work to close the gaps. iOS and Android have different divergence axes — iOS diverges by backend (tree / sweep / private AX), Android diverges by API level and by layer (platform parser vs daemon) — but the contracts and the target architecture are the same, and the shared-code fixes land once for both platforms.
The contracts (canonical statements in #1797):
- C1 Fact-availability neutrality — eligibility and derived fields never depend on whether an optional acquired fact was available; evidence is internal, never wire bytes.
- C2 Hint conservatism — an acquisition-shaping hint may reduce acquisition only when the adapter guarantees completeness for the requested projection; otherwise budget/order only.
- C3 Two projections — regular (clip, eligibility, hints, scope) vs raw (normalization only, no visibility pruning);
interactive ⊆ raw. - C4 Geometry carrier — regular emits the effective visible rect as
rect(tap centering correct by construction); raw emits raw frames; raw geometry is presentation-internal; post-wire consumers use effective geometry by declaration. - C5 Presentation failure contract — cooperative deadline, whole-tier discard with a named reason, separate acquisition/presentation timings, linear-complexity gate + hostile-tree benchmark.
- C6 Output invariant + disclosure — every framed node emitted by regular presentation intersects its cumulative effective clip; violations downgrade a quality verdict the backend actually emits.
#1797 additionally distinguishes eligibility (owned by presentation — the single-interpreter, backend/axis-neutral guarantee) from publication membership (owned by the daemon compaction layer — declared, test-pinned suppressions such as iOS's structural testID-wrapper rule). Android conformance uses the same vocabulary.
Audit scorecard (HEAD 3020195)
Android's good news first: one acquisition source (the helper APK — no uiautomator fallback at HEAD), one XML parser (readAndroidUiNodeMetadata, shared by snapshot / fill-verification / content-recovery), and one daemon presentation shared with iOS. The iOS two-interpreter disease is absent at the parser level. The violations are cross-layer instead:
| Contract | Verdict | Evidence |
|---|---|---|
| C1 neutrality | VIOLATED | drawing-order is emitted only on API ≥ 24 (AccessibilityTreeXml.java:125; helper minSdk 23), and the occlusion pruner requires it on both covered node and coverer (src/platforms/android/ui-hierarchy.ts:619,644). On API 23 pruneAndroidCoveredSubtrees silently no-ops device-wide — the same screen yields a different node set on API 23 vs 24+, in regular and raw, with zero disclosure. (Omit-false vs explicit-false encodings are fine — the parser normalizes === true, ui-hierarchy.ts:501.) |
| C2 conservatism | VIOLATED | Scope is applied twice with contradictory semantics: platform findScopeNode (BFS substring, falls back to the full tree on no-match, ui-hierarchy.ts:141) then daemon scopeSnapshotNodes (document-order, returns [] on no-match, src/daemon/handlers/snapshot-capture.ts:246). iOS D3, reincarnated across layers. Also: the helper truncates at 5000 nodes before any scope filter, so a scoped target past the cap silently vanishes (only generic truncated: true). |
| C3 two projections | PARTIAL | interactive ⊆ raw holds structurally (options.raw only bypasses membership, ui-hierarchy.ts:182), but pruneAndroidInvisibleSubtrees / discardInactiveAndroidApplicationWindows / pruneAndroidCoveredSubtrees run inside parseUiHierarchyTree (:524-529) — before both projections. Android --raw is not the acquired tree; pruned content is unrecoverable even in raw. |
| C4 geometry | VIOLATED | Tap points are raw-rect midpoints (src/core/interaction-touch-point.ts:26, pinned by contracts/fixtures/tap-point-policy.json); the off-screen guard only needs edge-overlap with the nearest scroll ancestor + center-in-root-viewport (packages/contracts/src/snapshot-visibility.ts:100), so a row half-scrolled under a sticky header is tapped at a midpoint outside its visible sliver. |
| C5 failure contract | PARTIAL | Acquisition is well-fenced (device-side stabilizer deadline, fail-closed, no partial payloads — AccessibilityCaptureStabilizer.java:56; ahead of iOS). Parse/present has no deadline and no complexity gate; the sibling-cover scan is worst-case quadratic in sibling width, and the daemon annotator's own history (documented O(2^n) until memoized, src/snapshot/snapshot-occlusion.ts:44) proves the class bites. |
| C6 invariant + verdict | ABSENT | SnapshotQualityVerdict backends are iOS-only ('tree' | 'queries' | 'private-ax', packages/kernel/src/snapshot.ts:16); Android never emits one, so no degraded presentation can be disclosed. Android also emits no root node, so viewport resolution rides a heuristic fallback with no output invariant guarding it. |
Additional findings outside the contracts:
src/daemon/android-system-dialog.ts:264hand-rolls a third presentation call site (attachRefs(pruneGroupNodes(...)), skipping normalize/occlusion/scope) — the fix: stabilize private AX settle snapshots #1784 pattern, daemon-side.- The helper never serializes
checked/checkable/selected/long-clickable, so toggle state is invisible to agents — whilebuildSnapshotSignatures(src/daemon/android-snapshot-freshness.ts:94) keys onselected, a field Android can never carry. Undeclared residue. - Two occlusion engines with different rules: platform 0.9-area-coverage pruner vs daemon center-point annotator — resolved by the shared occlusion-ownership decision below.
hittable=clickable || focusable || focused(ui-hierarchy.ts:534), no geometry — a cross-platform D2 analog once iOS unifies on geometric actionability.- API-level cache-clear divergence (
automation.clearCache()on API ≥ 34 vssetServiceInfoflush below,AccessibilityTreeCapture.java:72) — undisclosed acquisition-freshness difference.
Architecture: before → after
Before — interpretation scattered across layers, each ✕ a divergence point:
flowchart LR
subgraph iOS runner
A1[tree acquire+interpret] --- A2[sweep acquire+interpret] --- A3[privateAX acquire+interpret]
end
subgraph Android
B1[helper APK acquire] --> B2["platform parser: parse + prune(invisible/stale/occluded) + membership + scope#1 + hittable ✕"]
end
A1 & A2 & A3 --> W[SnapshotNode wire]
B2 --> W
W --> D["daemon: normalize + group-prune + scope#2 ✕ + noise + occlusion annotator ✕ + refs"]
D --> AG[agent]
After — same three-layer shape on every platform; interpretation exists once per runtime, pinned to one spec; scope lives inside both presentation runtimes, never in the post-wire layer:
flowchart LR
subgraph Acquisition adapters
IA[iOS: tree / sweep / privateAX]
AA[Android: helper APK → XML]
end
subgraph "Presentation (one spec, two runtimes, fixture-pinned; owns eligibility + scope)"
PS["Swift: presentRegular/presentRaw + scope matcher (runner, PresentedNode)"]
PT["TS: presentRegular/presentRaw + scope matcher (daemon-hosted shared core)"]
end
O[PresentationOptions] -->|"captureHint() under C2"| IA & AA
IA -->|RawAXNode + internal evidence| PS
AA -->|RawAXNode| PT
PS & PT -->|"rect = effective (regular) / raw (raw), C4"| W2[SnapshotNode wire + quality verdict]
W2 --> DA["daemon compaction layer: publication membership (declared noise/row policies) · occlusion annotator (sole owner) · refs · C6 invariant — NO scope"]
DA --> AG2[agent]
Key holistic decisions this encodes:
- One spec, two runtimes. iOS presentation must stay Swift/runner-side (capture-plan tier acceptance consumes presented output, per iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797); Android presentation is the TS shared core (hosted in the daemon process, but architecturally the presentation stage, not the post-wire compaction layer). Cross-runtime equivalence is pinned the way
tap-point-policy.jsonalready pins the Swift/TS tap-point twin: a shared golden conformance suite ofRawAXNode → Presentedfixtures run against both implementations. - Scope is part of the presentation spec (external pass 4): one specification with one no-match semantics; Swift implementation inside iOS
presentRegular/presentRaw(also validating conservative scoped acquisition); TypeScript implementation inside Android presentation; golden fixtures prove equivalence. The daemon's post-wire iOS scope pass and Android's platformfindScopeNodeare both removed. - Android platform layer slims to pure parse (XML →
RawAXNode): the three pruners move into the regular projection (fixes C3), membership/eligibility moves into the shared presentation,hittableunifies on geometric actionability with platform policy as an explicit parameter. - Shared-code fixes land once for both platforms: the C4 geometry carrier (
interaction-touch-point.ts+snapshot-visibility.tsare already shared; migration carries the rect-consumer inventory from iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797), occlusion ownership (daemon annotator becomes the sole occlusion implementation — resolving iOS's runner-scan duplication and Android's pruner/annotator split), and the scope specification above. - Divergence axes get differentials: iOS runs tree-vs-private-AX; Android runs API-23-vs-24+ acquisition profiles on the same screen, plus helper-version skew. Deterministic fixture differentials per PR, live nightly, multi-dimensional parity ratchets beside the differential manifest (iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797's cadence).
Work plan (types before semantics, aligned with #1797's migration order)
Immediate fixes (independent of the redesign):
- C1 (disclosure-only interim — a disclosure field alone does NOT restore neutrality): the target state is removing occlusion-based membership pruning entirely and making the daemon annotator the sole occlusion owner, uniformly on every API level. Because the pruner exists to hide covered React Native navigation surfaces (fix(android): stop empty focusable overlays from hiding app content #1737), removal lands gated on verification that the daemon annotator (upgraded as needed) covers the pruner's true positives across the 33-screen corpus and live Pixel captures. Until that gate passes, the interim patch is disclosure-only —
occlusionScanUnavailablein backend metadata on API 23 — and C1 remains violated, tracked explicitly here, closed by migration step 5 at the latest. - C2: collapse the double scope pass — remove platform
findScopeNode(Android acquisition is scope-blind; it buys nothing) and retire the post-wirescopeSnapshotNodespass in favor of the scope matcher inside the TS presentation, implementing the shared scope specification with its single defined no-match semantics (aligned with iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797's scope placement; the golden scope fixtures land with it).
Aligned migration:
3. Introduce the RawAXNode fixture schema for Android (the parser's output type, behavior-preserving) + route the system-dialog call site through the one presentation.
4. Move the three pruners out of parseUiHierarchyTree into the regular projection (C3); raw becomes normalization-only.
5. Adopt the shared geometry carrier (C4, with the rect-consumer inventory) and occlusion ownership — lands with #1797's shared-code steps; before landing, inventory consumers that equate hittable: false with "covered" and migrate them to the daemon's structured occlusion result. This step closes C1 fully if the immediate-fix gate hasn't already.
6. Presentation deadline + complexity gate + hostile-tree benchmark for the pruner/annotator (C5).
7. Extend SnapshotCaptureBackend/verdict with android-helper; check the C6 invariant in buildSnapshotState for all mobile backends.
8. Declare residues: missing checked/checkable/selected/long-clickable (and fix the freshness signature keying on selected), truncation×scope, API-34 cache-clear divergence.
9. Cross-runtime conformance suite (golden RawAXNode → Presented pairs incl. scope fixtures, Swift + TS) + API-level differential legs; ratchets beside the manifest, each parity dimension ratcheted separately and re-baselines reviewed as separate intentional deltas.
Testing lanes (scene-model oracle with shared semantic core + platform window/input extensions; property fuzz through the public interface; real-app corpus as distribution/regression evidence with decision-path + pairwise coverage as the stopping criterion) are tracked under the #1781 umbrella and shared with #1797 — the corpus/scene-model strategy gets its own issue before lane-2 implementation. Android's lane-2 projections get calibrated against the authored fixture app with paired live captures, seeded by the existing Pixel 9 XMLs and the #1737 33-screen corpus.
Relationship to siblings
- iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797 — iOS conformance + the canonical contract statements (C1–C6), the eligibility/publication vocabulary, and migration ordering.
- Prune the test lanes that don't pay for themselves, and add the checks we're missing #1781 — testing-strength umbrella owning the shared lanes.
- [Android] snapshot returns 1 node on Telegram while stock uiautomator dump sees the full tree (descent stops at depth 4) #1733 / [Android] Contentless full-screen containers count as occluders, pruning the entire app tree from snapshot #1806 / fix(android): let a covering sibling hide only what its content covers #1808 — the Android pruner bug class this issue's C1/C5 work fences permanently.
Activity
thymikee commentedon Aug 18, 2026
Started implementation. Sequencing (Android-only slices first, shared steps aligned with #1797's migration order):
PR 1 — immediate fixes 1 + 2 — #1846: C2 single scope pass + C1 disclosure-only.
matchesSnapshotScopein@agent-device/contracts/snapshotandcontracts/fixtures/snapshot-scope-policy.json(first document-order match over label/value/identifier, case-insensitive substring; subtree re-rooted at depth 0; no match → empty). It codifies what agents already observe at HEAD via the daemon pass. Android's projection and the daemonscopeSnapshotNodesare asserted against it; iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797's Swift scope-in-presentation should consume the same table (note: the iOS runner'sfindScopeElementtoday falls back toappon no match — same reincarnation as Android's platform fallback; the spec says empty).androidSnapshot.occlusionScanUnavailableon API-23-shaped helper trees. C1 remains violated, as planned.PR 2 — steps 3/4 + residues: the three pruners move out of
parseUiHierarchyTreeinto the regular projection (--raw= acquired tree, C3); the system-dialog call site goes throughbuildSnapshotState; declare residues (missingchecked/checkable/selected/long-clickable, freshness signature keying onselected, truncation×scope, API-34 cache-clear).Then, shared with #1797 in that order: C4 geometry carrier + rect-consumer inventory, occlusion ownership (daemon annotator sole owner → closes C1), C5 deadline/complexity gate + hostile-tree bench, C6
android-helperverdict +buildSnapshotStateinvariant, cross-runtime conformance suite + API-level differential.thymikee commentedon Aug 18, 2026
Adversarial review pass on #1846 found one blocker in the C2 work, now fixed — worth recording here because it changes the scope specification and the iOS sibling inherits it.
Blocker:
captureSnapshotDataspreadsnapshotScope: undefinedover flags, so interaction captures (press|click|fill|longpress|hover <selector> --scope X,--settleobservation) reached the Android platform unscoped whilebuildSnapshotStatestill saw the scope. Harmless while the post-wire pass existed; once C2 skips it forandroid, it becomes a silently unscoped tree — wrong-element taps or spuriousAMBIGUOUS_MATCH. Same trap awaits #1797 the moment the daemon's iOS scope pass is removed: any capture path that carries scope only inflagsmust be audited before that removal, and the durable fix is to make the platform result carry scope-resolution evidence rather than keying the skip on backend name.Specification sharpened (the golden table follows): scope matches over the presented tree of the requested projection (after membership), not the acquired tree — otherwise an acquired match that membership drops empties the snapshot, and the two platforms resolve in different domains. Android now slices after the walk, so ancestor context above the scope root still shapes
-imembership inside it (scoped-i⊆ unscoped-i) and--depthstays scope-relative.findSnapshotScopeRange/reindexSnapshotNodesin@agent-device/contracts/snapshotare the one implementation both TS runtimes call.fix(android): resolve snapshot scope once and disclose the API 23 occ…
fix(android): resolve snapshot scope once and disclose the API 23 occ…
fix(android): resolve snapshot scope once and disclose the API 23 occ…
thymikee commentedon Aug 18, 2026
PR #1846 merged (
294654a3a) — immediate fixes 1 and 2 are done:--scoperesolves once, inside the Android projection: first document-order match whose subtree still contributes content to the requested projection, that subtree re-rooted at depth 0, empty on no match. The two failure modes that rule exists to kill: matching the acquired tree alone empties-i --scope Xwhen the first match is decorative and membership drops it; matching only presented nodes empties-i --scope panelwhen the match is a structural container membership drops (real shape: launcherid/hotseatwrapping the Phone/Messages icons). Scoped--depthfilters the depths the response prints. Pinned bycontracts/fixtures/snapshot-scope-policy.json+matchesSnapshotScope/findSnapshotScopeRange/reindexSnapshotNodesin contracts, which the Swift twin should consume.androidSnapshot.occlusionScanUnavailable); C1 itself stays violated until occlusion moves to the daemon annotator, as planned.Two notes for #1797, both from this PR's review rounds:
flags.snapshotScope; a capture path that drops it whilebuildSnapshotStatestill sees it becomes a silently unscoped tree the moment the post-wire pass is skipped for that backend. fix: derive iOS transition snapshots from visible presentation #1831 sidesteps it for iOS today by stripping scope from the capture flags and scoping post-wire — but when iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797 moves scope into Swift presentation and removes the post-wire iOS pass, every path that carries scope only in flags must be audited. The durable fix is scope-resolution evidence on the platform result rather than keying the skip on backend name.--depthcuts the walk by acquired tree depth, which can differ from the compacted depth printed beside each node, so-i --depth 0can return nothing while the response would have printed nodes at depth 0. Scoped--depthnow filters printed depths. Worth unifying under C3/C4 rather than leaving two depth notions.Next up here is PR 2: the three pruners move out of
parseUiHierarchyTreeinto the regular projection (C3), the system-dialog call site routes throughbuildSnapshotState, and the residues get declared (checked/checkable/selected/long-clickable, freshness signature keying onselected, truncation×scope, API-34 cache-clear). #1848 having landed, I'll re-read the iOS presentation seam first so the shared steps line up.thymikee commentedon Aug 19, 2026
PR 2 open: #1865 — migration steps 3, 4 and 8 (Android-owned).
parseUiHierarchyTreeinto the regular projection as a non-mutating classification, so--rawis the acquired tree andinteractive ⊆ regular ⊆ rawholds by construction. Property differential vs the previous implementation: 12,000/12,000 identical regular/-i/depth/scope projections over 3,000 random trees; raw grew on 2,514 and never shrank. The move exposed two real bugs the mutation had hidden — the overflow estimate and the presentation-leaf test both countednode.childrenafter pruning had emptied them, so hidden-content hints and covered-sibling decisions silently depended on which projection ran first. Both now count the children of the projection being built.buildSnapshotState, moved tosrc/daemon/snapshot-state.tsso the daemon-server type cycle stays at its baseline).CONTEXT.md: nochecked/checkable/selected/long-clickable, the 5000-node cap applied before scope, API-level cache-reset divergence. The freshness route signature keyed onroleandselected— fields Android never carries — and no longer does.What is left before this issue can close, and why none of it is Android-only:
interaction-touch-point.tsandsnapshot-visibility.tsare shared with iOS, and the step carries the rect-consumer inventory from iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797. Occlusion ownership (daemon annotator becomes the sole implementation) is what closes C1, and Android native-a11y → snapshot: conform to the acquire/present engine contracts (sibling of #1797) #1832 gates it on verifying the annotator covers the pruner's true positives across the 33-screen corpus and live Pixel captures — that corpus is not in the repo, so the gate needs the Prune the test lanes that don't pay for themselves, and add the checks we're missing #1781 corpus work or an equivalent live run.SnapshotQualityVerdict.backendis an iOS strategy union (tree|queries|private-ax) validated against a closed set insrc/snapshot-quality/verdict.ts; addingandroid-helpertouches that validator, the warning renderer and the latch, and Android has no capture-plan classify step, so the producer would be daemon-side. The C6 invariant itself needs a cumulative effective clip, which today's helpers do not compute (resolveEffectiveViewportRectstops at the nearest scrollable ancestor) — that is C4 work.So #1832 can close once steps 5–7 and 9 land, and each of those is now blocked on or shared with #1797 rather than on Android. If you want them tracked separately so this issue can close on the Android-owned work, I'd split them into a "shared snapshot-engine steps" issue owned jointly by #1797/#1832 — say the word and I'll open it with the analysis above.
refactor(android): raw is the acquired tree; one presentation for dia…
refactor(android): raw is the acquired tree; one presentation for dia…
thymikee commentedon Aug 24, 2026
Completed by the Android snapshot-conformance stack:
The remaining broader presentation-boundary work is intentionally owned by #1983. API 23 still lacks exact sibling-order facts, so it conservatively publishes no guessed covered verdict; future shared policy/producer work belongs to that architectural track rather than another Android-local patch.
test(android): read ANR recovery through the Android capture envelope
test(daemon): stub the dialog probe where this branch moved it
test(android): read ANR recovery through the Android capture envelope
test(daemon): stub the dialog probe where this branch moved it
perf(android): one dumpsys read per window question; batch text via t…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): keep platform parsing with its family; give snap…
refactor(platforms): break the four upward edges out of src/platforms (…