Skip to content

Gate the Android accessibility publish on assistive-tech state and batch it at Compose parity - #497

Merged
samoylenkodmitry merged 2 commits into
mainfrom
fix/android-a11y-publish-gating
Aug 28, 2026
Merged

Gate the Android accessibility publish on assistive-tech state and batch it at Compose parity#497
samoylenkodmitry merged 2 commits into
mainfrom
fix/android-a11y-publish-gating

Conversation

@samoylenkodmitry

@samoylenkodmitry samoylenkodmitry commented Aug 27, 2026

Copy link
Copy Markdown
Owner

This is a correctness and battery fix. It does not improve frame rate.

The stage numbers below are real and reproduce. They do not convert into
fps, and the PR should not be merged expecting a faster scroll. A
same-package, same-data, same-thermal-state A/B on cranscan:

arm fps sync p50
baseline rep 1 41.60 5.12 ms
baseline rep 2 40.34 5.16 ms
patched 40.55 0.00 ms

That is one noise band. Stage sums say why: ~22.4 ms → ~19.4 ms against a
~16.1 ms vsync budget. The frame misses the same vsync either way — you do
not get paid until you cross 16 ms. Confirmed a second way by flipping
debug.cranpose.a11y_sync inside one binary: 41.47 fps off vs 41.51 fps on.

Publishing an accessibility tree every frame while nothing is listening is
pure waste and worth removing on its own terms. The actual scroll work is
#500, whose ranked causes (35 render passes, 9 isolated layers, 34.6 %
layer-cache hits) were re-verified on a quiet device and are the critical
path.

Problem

Scroll on a 2018 Huawei (EVR-AL00, Kirin 980, 60 Hz) is laggy in cranpose apps. On-device per-stage frame telemetry (debug.cranpose.frame_telemetry) showed the sync stage — the Android accessibility bridge — costing 6.3–6.7 ms of every 16.7 ms frame while scrolling, the single largest CPU stage of the loop (frame CPU p50 ≈ 12.7 ms, zero headroom).

Every frame whose semantics revision moved (= every scrolled frame) did a full layout+semantics tree walk, a full TSV encode, a JNI hop, and a Java-side parse on the frame thread — whether or not any assistive technology was running.

Confirmed by removal: a probe build skipping the sync dropped frame CPU p50 from 12.7 ms to 5.5–9.7 ms.

Fix

  • New accessibility_publish_policy.rs — pure, host-tested policy (9 unit tests): publish nothing while no assistive technology is active; while one is, publish at most once per 100 ms — Jetpack Compose's SendRecurringAccessibilityEventsIntervalMillis.
  • CranposeActivity mirrors AccessibilityManager state into native (nativeOnAccessibilityStateChanged, registered in onCreate, removed in onDestroy); enable transitions force a full republish.
  • A publish deferred by the throttle window arms a poll-timeout wake, so the trailing tree state lands even when the loop goes idle.
  • The Java payload parse moved off the frame thread into the posted UI-thread task.
  • A/B knob: debug.cranpose.a11y_sync / CRANPOSE_A11Y_SYNC (0 force off, 1 force on).

Measured on device (demo lazy list, EVR-AL00)

metric before after (a11y service active)
sync stage mean 6.4 ms/frame 0.3 ms/frame
sync per publish 6.5 ms every frame ~2.4 ms, 10×/s
frame CPU p50 12.7 ms ~9 ms (5.5 ms bridge off)

uiautomator dump (reads through the real AccessibilityNodeInfo path) still returns the complete virtual tree at the scrolled position, so screen readers keep working with Compose-equivalent freshness. This device runs a phone-control accessibility service permanently, so the throttled path (not just the off path) is what fixed it here.

Gates

just fmt / typos / versions / test / clippy / doc / just android all green. just budgets fails on the macOS dev host only (pre-existing objc2* duplicate families; the CI gate runs on Linux where those crates never resolve — no manifests or lockfile touched here). Robot suite untouched: all changed code is Android-target-gated or test-only.

Also included: TIME_WASTERS.md entry on Android on-device scroll-perf measurement traps, and a .gitignore for the gradle-plugin's local build dirs.

🤖 Generated with Claude Code

@samoylenkodmitry

Copy link
Copy Markdown
Owner Author

Device trace: this is the top cost in a real app, not a micro-optimisation

Traced cranscan (real app, 15-document library with thumbnails) on the Huawei EVR-AL00, thermals cool and verified unthrottled (GPU 37 °C, CPU 41-42 °C, mStatus=0). Per-stage medians during a long-drag scroll:

stage Library (glass cards) Settings (no glass)
update 4.59 ms 4.74 ms
sync (this PR) 5.55 ms 7.10 ms
acquire 0.58 ms 0.58 ms
render (CPU encode) 8.17 ms 5.96 ms
present 15.39 ms 9.44 ms
CPU excl. present 18.89 ms 18.38 ms
frame period 34.96 ms (~28 fps) 28.14 ms (~35 fps)

Two things fall out:

The GPU is not the bottleneck. acquire is 0.58 ms on both pages. If the GPU were behind, get_current_texture would block waiting for a free swapchain image; it doesn't, so the swapchain always has one ready. The long present is the pacing wait behind a CPU frame that already blew its 16 ms budget.

The cost is not the glass, either. A page with six-to-eight isolated glass layers and one with none have nearly identical CPU per frame (18.89 vs 18.38 ms). Whatever is eating the budget is paid on every screen.

sync — the per-frame accessibility publish this PR gates — is 5.5-7.1 ms of that, i.e. 29-39 % of the frame's CPU, and it is pure overhead: no assistive technology is consuming most of it, and what is consuming it does not need it at 60 Hz.

Subtracting just this stage puts both pages under one vsync (13.3 ms and 11.3 ms against a 16 ms budget), which is the difference between quantising to two vsyncs and hitting the panel rate. The mechanism is already A/B-proven on the demo in this PR's own numbers: cpu p50 12.5 → 5.5 ms with the bridge gated.

I have not yet built cranscan against this branch, so I am not claiming the fps number — only that the stage this PR removes is the single largest addressable item in that frame, measured. That build is the obvious next verification.

Worth noting for prioritisation: this reorders the work in #500. I had assumed cranscan's 15-17 fps was GPU-bound on backdrop blur; the trace says the render-pass and backdrop-cache work there, while real, is second to this.

@samoylenkodmitry
samoylenkodmitry force-pushed the fix/android-a11y-publish-gating branch 2 times, most recently from 9b5fdc7 to d94793d Compare August 27, 2026 16:08
samoylenkodmitry and others added 2 commits August 28, 2026 00:16
…tch it at Compose parity

The Android bridge re-projected, re-encoded, and re-published the whole
semantics tree over JNI on every frame whose semantics revision moved —
every scrolled frame — whether or not any assistive technology was
running, and parsed the payload on the calling frame thread. Measured on
a Kirin 980 (EVR-AL00) scrolling the demo lazy list, that stage cost
6.3-6.7 ms of the 16.7 ms frame budget, the largest CPU stage of the
loop; removing it dropped frame CPU p50 from 12.7 ms to under 10 ms.

CranposeActivity now mirrors AccessibilityManager state into the frame
loop, and a tested AccessibilityPublishPolicy publishes nothing while no
assistive technology is active and at most one snapshot per 100 ms while
one is (Jetpack Compose's recurring-events interval). A publish deferred
by the throttle window arms a poll-timeout wake so the trailing tree
lands even when the loop goes idle, the Java-side payload parse moved
into the posted UI-thread task, and debug.cranpose.a11y_sync /
CRANPOSE_A11Y_SYNC forces the bridge off or on for A/B diagnostics.

On device with an accessibility service active, the sync stage mean fell
from ~6.4 ms to ~0.3 ms and uiautomator still dumps the full virtual
tree at the scrolled position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y on publishes

The demo Liquid tab bumps the semantics revision every scrolled frame
while the projected elements stay identical. In that path the bridge ran
the full snapshot walk and deep compare per frame (sync p50 2.3 ms on
the Kirin 980) because only a successful publish started a new throttle
window. The probe is the cost being rationed, so try_begin_publish now
starts the window itself; measured sync mean fell to 0.55 ms with p50 at
zero on the same scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@samoylenkodmitry
samoylenkodmitry force-pushed the fix/android-a11y-publish-gating branch from d94793d to b88cbb3 Compare August 27, 2026 22:24
@samoylenkodmitry
samoylenkodmitry merged commit edb5b2b into main Aug 28, 2026
7 checks passed
samoylenkodmitry added a commit that referenced this pull request Aug 28, 2026
…t finds (#517)

* Gate the Android target on clippy, and fix the one real lint it finds

Android was the only shipped target with no zero-warning gate. The justfile
has clippy (host), clippy-wasm and clippy-ios, all -D warnings; android only
runs ./gradlew :app:assembleRelease, which does not deny warnings, and no
workflow sets -D warnings for an Android target. Anything behind an
android-only cfg reached main unlinted, which is how #497's dead accessor
got there.

clippy-android mirrors what CranposeAndroidPlugin passes to cargo ndk rather
than inventing a feature subset: -p desktop-app-platform --lib
--no-default-features --features android,renderer-wgpu, and --platform from
the demo's minSdk.

missing_const_for_thread_local is allowed for this recipe only. It fires 16
times on Android and never on host, on the same pinned toolchain and source,
including on initializers already written as const {} and on one that cannot
be const at all. thread_local! expands per-target and the Android expansion
defeats the lint's const detection. It stays enabled everywhere else.

The needless_return in http.rs is a genuine android-only lint, in the
cfg(target_os = "android") branch of configure_native_client_builder.

* Lint every Android ABI, and fix the 32 warnings that found

Android was the only shipped target with no zero-warning gate. `just android`
runs Gradle, which does not deny warnings, and no workflow set `-D warnings`
for an Android target, so anything behind an `android`-only `cfg` reached main
unlinted. #497's dead accessor is how that surfaced; it was not the only one.

`clippy-android` mirrors what `CranposeAndroidPlugin` hands `cargo ndk` rather
than inventing a feature subset: `-p desktop-app-platform --lib
--no-default-features --features android,renderer-wgpu`, `--platform` from the
demo's `minSdk`. CI runs it before the APK build, so a lint failure does not
wait behind a full release build, and `ci-full` now names it — along with
`clippy-ios`, which that aggregate had also been omitting while claiming to be
every gate.

It lints all four ABIs `releaseAbis` ships under CI, not just arm64, and that
is load-bearing rather than thorough-for-its-own-sake. `libc::timespec::tv_sec`
is `i64` on the 64-bit ABIs and `i32` on armeabi-v7a and x86. An arm64-only
run reports the `as i64` on that field as an unnecessary cast, and `--fix`
duly removed it — which would have been a type error on the two 32-bit ABIs
that CI builds. A single-ABI gate would have shipped that.

That field has no lint-clean spelling: `as i64` trips `unnecessary_cast` on
64-bit, `i64::from` trips `useless_conversion` there instead, and dropping the
widening breaks 32-bit. It keeps `i64::from` and allows the 64-bit complaint on
that function alone, which states the widening intent rather than hiding it.

The remaining 31 are mechanical and were reviewed rather than trusted:
18 `collapsible_if` (all `let`-chains with no `else`, so equivalent),
12 `unnecessary_cast` on values already `f32`, and one `needless_borrow`
passing `&&Adapter`.

`missing_const_for_thread_local` is allowed for this recipe only. It fires 16
times on Android and never on host, same pinned toolchain and source, including
on initializers already written as `const {}` and on one that cannot be const
at all. `thread_local!` expands per-target and the Android expansion defeats
the lint's const detection. Enabled everywhere else, so a genuine non-const
initializer is still caught by `just clippy`.

Verified: `just clippy-android` (four ABIs), `just clippy`, `just test`,
`just fmt`, `just android`.
@samoylenkodmitry
samoylenkodmitry deleted the fix/android-a11y-publish-gating branch August 28, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant