Skip to content

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

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-onlyocclusionScanUnavailable 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

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