Skip to content

Android native-a11y → snapshot: conform to the acquire/present engine contracts (sibling of #1797) #1832

Closed

Description

@thymikee

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:264 hand-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 — while buildSnapshotSignatures (src/daemon/android-snapshot-freshness.ts:94) keys on selected, 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 vs setServiceInfo flush 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]
Loading

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]
Loading

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.json already pins the Swift/TS tap-point twin: a shared golden conformance suite of RawAXNode → Presented fixtures 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 platform findScopeNode are 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, hittable unifies 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.ts are 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):

  1. 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 — occlusionScanUnavailable in backend metadata on API 23 — and C1 remains violated, tracked explicitly here, closed by migration step 5 at the latest.
  2. C2: collapse the double scope pass — remove platform findScopeNode (Android acquisition is scope-blind; it buys nothing) and retire the post-wire scopeSnapshotNodes pass 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

Activity

  1. thymikee commented on Aug 18, 2026

    @thymikee
    MemberAuthor

    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.

    • The scope specification now exists as code + golden table: matchesSnapshotScope in @agent-device/contracts/snapshot and contracts/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 daemon scopeSnapshotNodes are 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's findScopeElement today falls back to app on no match — same reincarnation as Android's platform fallback; the spec says empty).
    • androidSnapshot.occlusionScanUnavailable on API-23-shaped helper trees. C1 remains violated, as planned.

    PR 2 — steps 3/4 + residues: the three pruners move out of parseUiHierarchyTree into the regular projection (--raw = acquired tree, C3); the system-dialog call site goes through buildSnapshotState; declare residues (missing checked/checkable/selected/long-clickable, freshness signature keying on selected, 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-helper verdict + buildSnapshotState invariant, cross-runtime conformance suite + API-level differential.

  2. thymikee commented on Aug 18, 2026

    @thymikee
    MemberAuthor

    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: captureSnapshotData spread snapshotScope: undefined over flags, so interaction captures (press|click|fill|longpress|hover <selector> --scope X, --settle observation) reached the Android platform unscoped while buildSnapshotState still saw the scope. Harmless while the post-wire pass existed; once C2 skips it for android, it becomes a silently unscoped tree — wrong-element taps or spurious AMBIGUOUS_MATCH. Same trap awaits #1797 the moment the daemon's iOS scope pass is removed: any capture path that carries scope only in flags must 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 -i membership inside it (scoped -i ⊆ unscoped -i) and --depth stays scope-relative. findSnapshotScopeRange/reindexSnapshotNodes in @agent-device/contracts/snapshot are the one implementation both TS runtimes call.

  3. thymikee commented on Aug 18, 2026

    @thymikee
    MemberAuthor

    PR #1846 merged (294654a3a) — immediate fixes 1 and 2 are done:

    • C2 closed for Android. --scope resolves 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 X when the first match is decorative and membership drops it; matching only presented nodes empties -i --scope panel when the match is a structural container membership drops (real shape: launcher id/hotseat wrapping the Phone/Messages icons). Scoped --depth filters the depths the response prints. Pinned by contracts/fixtures/snapshot-scope-policy.json + matchesSnapshotScope/findSnapshotScopeRange/reindexSnapshotNodes in contracts, which the Swift twin should consume.
    • C1 disclosure landed (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:

    1. The unscoped-capture trap is still armed on iOS. Interaction captures carry scope only in flags.snapshotScope; a capture path that drops it while buildSnapshotState still 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.
    2. Pre-existing, now documented: unscoped --depth cuts the walk by acquired tree depth, which can differ from the compacted depth printed beside each node, so -i --depth 0 can return nothing while the response would have printed nodes at depth 0. Scoped --depth now 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 parseUiHierarchyTree into the regular projection (C3), the system-dialog call site routes through buildSnapshotState, and the residues get declared (checked/checkable/selected/long-clickable, freshness signature keying on selected, truncation×scope, API-34 cache-clear). #1848 having landed, I'll re-read the iOS presentation seam first so the shared steps line up.

  4. thymikee commented on Aug 19, 2026

    @thymikee
    MemberAuthor

    PR 2 open: #1865 — migration steps 3, 4 and 8 (Android-owned).

    • C3 satisfied. The three pruners moved out of parseUiHierarchyTree into the regular projection as a non-mutating classification, so --raw is the acquired tree and interactive ⊆ regular ⊆ raw holds 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 counted node.children after 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.
    • One presentation for Android blocking-dialog recovery (buildSnapshotState, moved to src/daemon/snapshot-state.ts so the daemon-server type cycle stays at its baseline).
    • Residues declared at their sites + CONTEXT.md: no checked/checkable/selected/long-clickable, the 5000-node cap applied before scope, API-level cache-reset divergence. The freshness route signature keyed on role and selected — fields Android never carries — and no longer does.

    What is left before this issue can close, and why none of it is Android-only:

    1. Step 5 — C4 geometry carrier + occlusion ownership. interaction-touch-point.ts and snapshot-visibility.ts are 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.
    2. Step 6 — C5 deadline + complexity gate. Can land Android-side, but the natural shape is one presentation-failure contract for both runtimes; the existing template is the occlusion annotator's hostile-tree test.
    3. Step 7 — C6 verdict + invariant. SnapshotQualityVerdict.backend is an iOS strategy union (tree|queries|private-ax) validated against a closed set in src/snapshot-quality/verdict.ts; adding android-helper touches 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 (resolveEffectiveViewportRect stops at the nearest scrollable ancestor) — that is C4 work.
    4. Step 9 — cross-runtime conformance suite needs the Swift presentation from iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797 to exist as a callable target.

    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.

  5. thymikee commented on Aug 24, 2026

    @thymikee
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions