Skip to content

fix(android): stop empty focusable overlays from hiding app content - #1737

Merged
thymikee merged 3 commits into
mainfrom
claude/issue-1733-telegram-reproduction-469f5d
Aug 11, 2026
Merged

fix(android): stop empty focusable overlays from hiding app content#1737
thymikee merged 3 commits into
mainfrom
claude/issue-1733-telegram-reproduction-469f5d

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

On Telegram for Android, snapshot returned a single node and the "sparse accessibility snapshot"
hint on every screen. The app was completely unautomatable. Same for any app that wraps its screens
in an empty focusable overlay View.

The issue reported this as the helper failing to descend past depth 4. It isn't — the Android helper
and stock uiautomator dump both return the full tree. The loss was in the TypeScript parser's
covered-subtree pruner.

pruneAndroidCoveredSubtrees lets a sibling condemn a lower drawing-order sibling it geometrically
covers, gated on the covering node having "agent-visible content". That gate accepted any hittable
node, and hittable is clickable ?? focusable. Telegram wraps every screen in a childless,
textless, id-less full-screen android.view.View with focusable="true" and a higher drawing order
than the content container — so it qualified, and took the entire 37-node UI subtree with it.

Two changes:

  • Covering candidacy is keyed on clickable rather than hittable. Focusability is an
    accessibility-traversal property; it says nothing about painting over a sibling. A node that
    announces nothing and does nothing cannot condemn one that does.
  • The existing "never condemn a marker leaf" exemption (RN screen-level testIDs) is generalised to
    any childless, non-clickable sibling carrying its own text or identifier. Drawing order plus
    geometry cannot tell a transparent overlay from an opaque one, and exempting a leaf is bounded —
    it can never resurrect a covered surface.

Before / after on the Telegram phone-number screen:

Snapshot: 1 nodes
Hint: sparse accessibility snapshot returned 1 node; snapshot state is invalid or unavailable...
Snapshot: 19 visible nodes (21 total)
@e3  [text]       "Your phone number"
@e5  [group]      "Country"
@e6  [text-field] "Country code" [focused] [editable]
@e8  [text-field] "Phone number" [editable]
@e20 [group]      "Done"

Closes #1733.

Validation

Live, API 37 emulator (Android 17), Telegram 12.9.2, helper android-helper v0.20.8. Reproduced
the 1-node snapshot first, then drove the onboarding flow end-to-end through the built CLI on the
fixed build: press 'text="Start Messaging"' and a follow-up press @e6 both resolved and tapped by
selector, and the phone-number screen exposes both EditTexts with correct roles. Sessions closed.

Isolated root-cause proof. Ran the helper instrumentation directly and captured its XML: 46
Telegram nodes to depth 15, all text present. Feeding that exact XML to parseUiHierarchyTree gave 6
nodes; gating out pruneAndroidCoveredSubtrees gave 45. That localises the loss to the parser and
names the pruner, independent of any device timing.

Regression sweep across 33 real screens, captured from both a live API 36 and a live API 37
emulator, then replayed through the pre-fix and post-fix parsers on identical input in both
interactive and raw modes: Telegram (3 onboarding screens), the repo's Expo Router test app
running against Metro (5 tabs, bottom-sheet modal with dimming scrim, drill-in detail, native alert),
Bluesky and the Expo dev-launcher, Settings, Clock, Files, Messages, Play Store, Photos, Chrome,
Calendar, Contacts, and a permission dialog.

  • Zero screens lost content.
  • All 30 non-Telegram screens are byte-identical in node count and content — no token bloat.
  • Telegram recovers 5, 8, and 55 content nodes on the three screens (the country picker goes from
    nothing to the full list).
  • The pruner fires in the wild on only 4 of the 33 screens. Three are the Telegram bug. The fourth,
    Google Messages, is a legitimate prune and it still fires post-fix, dropping the same sibling.

Four new parser tests. Three fail against pre-fix code (3 failed | 26 passed); the fourth pins the
boundary that must not move — a genuinely clickable leaf behind a foreground sibling is still
condemned — and passes both ways by design.

Local gates green, including check:affected and check:fallow.

Notes and follow-ups

  • The RN stacked-navigation case the pruner was originally built for did not fire on any of the
    captured RN screens, including the bottom-sheet modal — Expo Router puts those in separate
    accessibility windows. That path is covered by the pre-existing unit test, which still passes, but
    the live sweep does not independently exercise it.
  • Separate, pre-existing issue surfaced once the screen became reachable: text will not enter
    Telegram's phone-number field, via fill, type, or coordinate taps on its in-app keypad. The
    field focuses but stays empty. Unrelated to this change — it was simply unobservable before, since
    there were no refs to target. Worth its own issue.
  • Two files touched; scope stayed within the Android snapshot parser.

…1733)

The covered-subtree pruner let any `hittable` sibling condemn a lower
drawing-order sibling it geometrically covers. `hittable` is
`clickable ?? focusable`, so a childless, textless, id-less full-screen
focusable View qualified as "agent-visible content" and dropped the
sibling holding the real UI.

Telegram wraps every screen in exactly such a View, which is why
`snapshot` returned 1 node on every Telegram screen while the helper and
stock `uiautomator dump` both saw the full tree — the loss was in the TS
parser, not the Android helper.

Key covering candidacy on `clickable` instead: focusability is an
accessibility-traversal property and says nothing about painting over a
sibling. Also generalise the existing "never condemn a marker leaf"
exemption to any childless, non-clickable sibling that carries its own
text or identifier, since drawing order plus geometry cannot distinguish
a transparent overlay from an opaque one.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.17 MB 2.17 MB -228 B
JS gzip 709.5 kB 709.4 kB -86 B
npm tarball 835.6 kB 835.6 kB -53 B
npm unpacked 2.91 MB 2.91 MB -228 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.9 ms 26.7 ms -0.1 ms
CLI --help 66.3 ms 65.9 ms -0.4 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/ime-lifecycle.js +135 B +41 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 39b9352. P1: preserve hittable when classifying foreground descendants. The helper emits clickable/focusable attributes only when true, so a focusable-only node parses as clickable undefined but hittable true. Changing hasActionableDescendant from child.hittable to child.clickable means a higher drawing-order surface containing focusable-only controls no longer qualifies as covering; covered background selector/ref targets can be retained and acted on, especially in Android TV/D-pad UIs. The Telegram fix needs to exclude the empty focusable wrapper itself, not discard focusable descendants. Keep descendant classification on the established hittable contract (or distinguish the wrapper another way) and add a helper-shaped regression where an omitted-clickable/focusable foreground descendant still suppresses a covered lower target. CI and Telegram/API36–37 evidence are otherwise strong.

Review feedback on #1733: narrowing hasActionableDescendant from
`hittable` to `clickable` was over-reach. The helper emits
clickable/focusable only when true, so a focusable-only control parses as
clickable=undefined, hittable=true — and D-pad/TV surfaces are built
almost entirely from those. A foreground surface containing them would
have stopped qualifying as covering, leaving background selector/ref
targets retained and actionable behind it.

Descendant classification returns to the established `hittable` contract,
and the leaf exemption returns to `!hittable` so a genuinely covered
affordance stays condemned. The Telegram fix needs only to exclude the
empty focusable wrapper itself, which is self-classification in
hasOwnAgentVisibleContent — that stays on `clickable`.

Replaces the test that asserted the rejected behavior with a
helper-shaped regression: a foreground descendant carrying focusable
without any clickable attribute must still suppress a covered target.
@thymikee

Copy link
Copy Markdown
Member Author

Fixed in c87f621 — you were right, and the TV/D-pad case is the one that would have bitten.

hasActionableDescendant is back on hittable, with a comment recording why it must stay there. I also reverted the leaf exemption to !hittable for the same reason: I had broadened it to !clickable, which would have retained covered focusable affordances — the same defect one level down, and one you'd have had to catch twice. The cost is that Telegram's focusable Web tabs leaf is condemned again (9→8 recovered content nodes on that screen), which is the correct call under the established contract.

What stays on clickable is only hasOwnAgentVisibleContent — self-classification of the covering node. A node that is only focusable, with no text, no id, and no actionable descendant, offers an agent nothing to target, so it can't be the reason a sibling is hidden. That is the whole Telegram fix; the descendant change was never needed for it.

Replaced the test that asserted the rejected behavior with the helper-shaped regression you asked for: foreground descendant carrying focusable="true" and no clickable attribute at all must still suppress a covered target. Verified non-vacuous — restoring the clickable variant fails it and nothing else (1 failed | 28 passed).

Re-ran the 32-screen A/B: still 0 screens losing content, all non-Telegram screens byte-identical. Re-verified live on API 37 — selector-driven press through the onboarding flow still works. Changed-branch coverage is now 10/11.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head c87f621. The prior focusable-descendant P1 is fixed: only empty covering-node self-classification narrows to clickable, while descendant coverage and the leaf exemption remain on hittable. The helper-shaped regression is load-bearing and preserves Android TV/D-pad foreground occlusion; Telegram and overlapped-label routes remain correct. Exact-head Telegram/API37 evidence plus the 32-screen A/B sweep is strong, all authoritative checks are green, and GitHub is CLEAN/MERGEABLE. No actionable finding remains; ready-for-human.

@thymikee thymikee added ready-for-human Valid work that needs human implementation, judgment, or maintainer merge and removed ready-for-human Valid work that needs human implementation, judgment, or maintainer merge labels Aug 11, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Supersedes my prior clean verdict on c87f621.

P1: the occlusion model remains encoding-dependent. hittable: attrs.clickable ?? attrs.focusable assigns different semantics to the same focusable-only node depending only on serialization: the helper omits false clickable attributes, producing hittable=true, while stock UiAutomator emits clickable="false", producing hittable=false. In the new foreground-occlusion regression, adding only clickable="false" to the two focusable nodes makes the foreground stop qualifying and leaves Background row exposed. That directly contradicts the existing Android TV test, which uses clickable="false" focusable="true" and requires hittable === false.

Please normalize this at the owning helper/parser boundary so omitted-false and explicit-false encodings cannot change semantics. If focusability is evidence for TV/D-pad occlusion, carry and use focusable explicitly instead of making attribute absence mean hittable. Add paired encoding-equivalence regressions for covered-subtree pruning and interactive projection, and re-prove the Telegram case red before / green after. Current helper-only live evidence and CI do not cover this boundary.

…g them

`hittable: clickable ?? focusable` collapsed two independent Android facts
and made behavior depend on how a producer encodes a false attribute. The
snapshot helper omits `clickable`; stock UiAutomator writes
`clickable="false"`. For one focusable control those encodings gave
opposite answers — verified: identical trees pruned differently and
projected opposite `hittable` values. No comment can hold an invariant the
representation contradicts, which is what the previous two commits tried
to do.

The tree now stores `clickable` and `focusable` as separate non-optional
booleans, and every decision reads a named predicate: isTouchTarget,
isFocusTarget, isAgentTarget, hasSemanticContent, hasDirectOcclusionEvidence,
hasDescendantOcclusionEvidence, isPresentationLeaf. `canCoverSibling`
consumes one derived classification (hasOcclusionEvidence) rather than
choosing between raw attributes, so the self-versus-descendant substitution
that caused the last review round is no longer expressible. Removing
`hittable` from the tree type made the typechecker find every construction
site; the public field is derived once at projection from isAgentTarget.

Behavior change: a focusable, non-clickable control encoded by stock
UiAutomator now projects hittable=true and participates in occlusion,
matching what the helper backend already did for the same control. The
Android TV test asserted the old encoding-specific value; it now asserts
inclusion plus the unified projection. One encoding-parity regression
replaces the patch-specific test added last round.
@thymikee

Copy link
Copy Markdown
Member Author

Revised in cc0c7a0. You were right that the comments were load-bearing where the model should have been, and the encoding divergence is real — I reproduced it before changing anything:

helper-shaped (clickable omitted)        background kept=false  foreground hittable=true
uiautomator-shaped (clickable="false")   background kept=true   foreground hittable=false

Same logical tree, opposite pruning and opposite public projection. So this was never just a pruner bug — the projection was already encoding-dependent on main.

What changed. The tree stores clickable and focusable as separate non-optional booleans; hittable is gone from AndroidUiHierarchy entirely and derived once at projection from isAgentTarget. Every decision now reads a named predicate — isTouchTarget, isFocusTarget, isAgentTarget, hasSemanticContent, hasDirectOcclusionEvidence, hasDescendantOcclusionEvidence, isPresentationLeaf — and canCoverSibling consumes a single hasOcclusionEvidence classification rather than picking between raw attributes. The self-versus-descendant substitution isn't expressible anymore. Dropping hittable from the type was the useful forcing function: the typechecker located every construction site, including a test fixture that was quietly feeding the collapsed value back in.

Most of the comments are gone; the file is down to 12 comment lines total.

One behavior change worth your call. A focusable, non-clickable control encoded by stock UiAutomator now projects hittable=true and participates in occlusion. That is a change for the fallback backend only — the helper backend already reported true for the same control, so this makes the two agree rather than inventing a third answer. The Android TV test pinned the old encoding-specific hittable=false; it now asserts inclusion (its actual subject) plus the unified projection. I did not take your suggestion of projecting public hittable from clickability alone: that would flip focusable-only TV controls to non-hittable, which changes snapshot-diff digests and sends them through findNearestAncestor promotion in snapshot-processing. Happy to do it, but it deserves its own PR and TV evidence rather than riding along here.

One encoding-parity regression replaces the patch-specific test I added last round (net −1 test). Re-ran the 32-screen A/B: still 0 screens losing content, all non-Telegram screens byte-identical. Live re-verified the full onboarding flow on API 37. Gates green, changed-line coverage 12/12.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head cc0c7a0. The prior encoding-dependent P1 is fixed holistically: omitted false attributes and explicit clickable="false" normalize to identical clickable/focusable facts before covered-subtree pruning, interactive inclusion, or public hittable projection. The named target/occlusion predicates keep the self-versus-descendant substitution from recurring, and the parity regression is load-bearing. No code finding remains.

Merge readiness is still gated on the pending checks. Nonblocking PR-body drift: update “Two files touched” to three, and describe the semantic-leaf exemption as applying to non-agent-target leaves rather than every non-clickable leaf.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 11, 2026
@thymikee
thymikee merged commit 943913a into main Aug 11, 2026
31 checks passed
@thymikee
thymikee deleted the claude/issue-1733-telegram-reproduction-469f5d branch August 11, 2026 17:13
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-11 17:15 UTC

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] snapshot returns 1 node on Telegram while stock uiautomator dump sees the full tree (descent stops at depth 4)

1 participant