You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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.
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.
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:
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.
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 — occlusionScanUnavailable in 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-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.
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):
interactive ⊆ raw.rect(tap centering correct by construction); raw emits raw frames; raw geometry is presentation-internal; post-wire consumers use effective geometry by declaration.#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:drawing-orderis 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 23pruneAndroidCoveredSubtreessilently 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.)findScopeNode(BFS substring, falls back to the full tree on no-match,ui-hierarchy.ts:141) then daemonscopeSnapshotNodes(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 generictruncated: true).interactive ⊆ rawholds structurally (options.rawonly bypasses membership,ui-hierarchy.ts:182), butpruneAndroidInvisibleSubtrees/discardInactiveAndroidApplicationWindows/pruneAndroidCoveredSubtreesrun insideparseUiHierarchyTree(:524-529) — before both projections. Android--rawis not the acquired tree; pruned content is unrecoverable even in raw.src/core/interaction-touch-point.ts:26, pinned bycontracts/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.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.SnapshotQualityVerdictbackends 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.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.hittable=clickable || focusable || focused(ui-hierarchy.ts:534), no geometry — a cross-platform D2 analog once iOS unifies on geometric actionability.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:
tap-point-policy.jsonalready pins the Swift/TS tap-point twin: a shared golden conformance suite ofRawAXNode → Presentedfixtures run against both implementations.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.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.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.Work plan (types before semantics, aligned with #1797's migration order)
Immediate fixes (independent of the redesign):
occlusionScanUnavailablein backend metadata on API 23 — and C1 remains violated, tracked explicitly here, closed by migration step 5 at the latest.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
RawAXNodefixture 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
parseUiHierarchyTreeinto 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: falsewith "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 withandroid-helper; check the C6 invariant inbuildSnapshotStatefor all mobile backends.8. Declare residues: missing
checked/checkable/selected/long-clickable(and fix the freshness signature keying onselected), truncation×scope, API-34 cache-clear divergence.9. Cross-runtime conformance suite (golden
RawAXNode → Presentedpairs 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