gallery/HANDOFF.md
Greg Pomerantz 02dec62405 M4: full-screen viewer with pinch/pan zoom
- src/viewer.rs: full-size decode (turbojpeg DCT shrink, 2x-screen cap
  bounded by GPU max texture, EXIF orientation applied) + transform
  model (fit/clamp/zoom_at) + single-finger pan & double-tap state
  machine. 18 unit tests.
- Pinch: Slint 1.18's ScaleRotateGestureHandler. Key finding: the CORE
  (TouchState in internal/core/input.rs), not the backend, synthesizes
  PinchGesture from raw two-finger touches on every backend including
  Android — the second finger never reaches a TouchArea, so manual
  two-finger math is dead code (the earlier draft was removed).
- main.slint: Viewer overlay (image + status + close button + full-
  surface TouchArea + ScaleRotateGestureHandler). The close button must
  be declared AFTER the full-surface TouchArea: 1.18 TouchArea grabs
  every press (GrabMouse) and aborts the front-to-back sibling
  traversal.
- lib.rs: open/close/pinch/pointer wiring; Android back key (via the
  vendor CloseRequested patch) closes the viewer first.
- vendor: androidwindowadapter back-key -> CloseRequested patch
  (Slint 1.18 has no back-key close handling).
- gputex.rs: max_texture_size() for the decode cap.
- HANDOFF: M4 done; new gotchas 6.14-16; emulator two-finger sendevent
  (MT-B) injection recipe.

Verified on emulator (pad_avd): real two-finger pinch zoom, double-tap
zoom/reset, pan, button + back close, grid scroll unaffected. Phone
(Pixel 9 Pro, release aarch64): grid, viewer open, 4080x3072 decode,
double-tap, back. Pinch not injectable on the user build (SELinux
blocks /dev/input writes) — covered by unit tests + emulator.
2026-10-29 10:51:56 -04:00

804 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gallery — development hand-off
A high-performance image gallery for Android (primary), Linux and macOS,
written in Rust with the **Slint** UI toolkit. This document is the starting
point for development: it captures every decision, gotcha, and measurement
from the framework evaluation that preceded it, so no learning has to be
rediscovered.
**Status: M3 done (2026-10-27, twice overhauled)** — M0 scaffold, M1 metadata
scan + sqlite + instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink
decode, worker pool, disk cache, cancellation), M3 grid UI (3-column ListView
grid, shimmer placeholders, memory-bound cell retention) all verified: 15
unit tests (incl. two queue-priority regressions), 5k synthetic engine test,
and on a real Pixel 9 Pro (8,577 photos: 60 fps, 0 hitches). The thumbnail
loader was rewritten TWICE on 2026-10-27 because "stop → long shimmer → small
move → everything pops" kept surviving: first the scroll handler forgot cells
when scrolling stopped (fix: **viewport reconciliation** — the 10 Hz tick is
the sole authority, the invariant "every viewport cell is painted / in flight
/ failed" is restored unconditionally each tick and `debug_assert`ed), then
the worker-level FIFO still let stale transit work delay a fresh viewport
(fix: **worker-queue prepend + cap** — bursts are prepended ahead of older
queued work; per-worker cap drops the oldest tail with a `skipped` result).
Stress-verified on the emulator (debug build) + phone before shipping:
fast up/down 0–120 ms to full coverage, cold landings bounded by decode
throughput, chaos scrolling max oldest in-flight job 217 ms (was 8 s),
0 invariant violations in ~1,450 ticks, 0 panics/ANRs. M4 viewer is
DONE (2026-10-29 — see §9); next is M5 permissions.
---
## 1. Goals
- Browse and view a local photo collection (thousands of images) that stays
smooth: instant grid, no decode jank, cached thumbnails.
- Full-screen viewer with pinch-zoom/pan.
- Correct EXIF orientation, HEIC support (Android camera default).
- Same codebase on macOS + Linux (desktop `cargo run`) and Android
(`cargo apk2`).
- Quality of the *platform experience* matters: safe-area insets, IME-free
UI, native-feeling widgets, animations — first-class support preferred over
manual patching.
Non-goals (for now): editing, cloud sync, video, widgets/live-updates.
## 2. Why Slint (decision summary)
Full evaluation in `/mnt/Backup/prog/android/ui/REPORT.md` (same tree:
`~/prog/android/ui/REPORT.md`), raw notes in `NOTES.md` there. Headline:
| | Slint 1.18.1 | eframe/egui 0.37.0 |
|---|---|---|
| Virtualized list (the gallery's lifeline) | ✅ `ListView` std-widget, only visible rows instantiated | ❌ `ScrollArea::show` lays out all children; `show_rows` = manual virtualization API |
| Async image → UI | swap `slint::Image` into a property; only that tile redraws (retained mode) | `ctx.load_texture` + custom loader; visible set rebuilt every frame |
| Lifecycle hook for permissions | ✅ `init_with_event_listener` (Start/Resume/Pause/Stop/SaveState/...) | ❌ none in `App` trait (winit swallows it) |
| Android safe-area | ✅ `safe-area-insets` built in | manual 32px-ish panel workaround (winit#3910) |
| Animation | ✅ declarative built-in engine | hand-rolled |
| IME text entry | ✅ | ✅ |
| APK size (release aarch64) | 8.57 MB | 2.87 MB |
| Clean build time (x86_64, this machine) | 195 s / 270 crates | 76 s / 137 crates |
| Desktop Linux system deps | `libfontconfig1` + `pkg-config` | none |
| License | royalty-free for proprietary mobile/desktop (embedded excluded) / GPLv3 / commercial | MIT OR Apache-2.0 |
| Min Android API | 26 | 24 |
For a stateful, form-and-grid app, the declarative model was the right fit
(see the discussion about `pad`'s immediate-mode frame friction); egui wins
for games/dashboards and on APK size. Known Slint caveat: an open upstream
"abort on pointer scroll" bug in the Android backend (`todo!()` in the
pointer-event path) — verify it's fixed in 1.19 before relying on heavy
scrolling; if it fires, the panic is obvious in logcat.
## 3. Environment, build & test loop
Everything below is verified working on this machine (2026-10-27).
```bash
export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
# Rust 1.99.1 via rustup; tools: cargo-apk2 1.5.0, cargo-ndk 4.1.2
./scripts/build.sh debug # debug x86_64 APK + adb install + launch
./scripts/build.sh # release x86_64 (emulator)
./scripts/build.sh aarch64 # release aarch64 (device)
cargo run # desktop (Linux needs libfontconfig1+pkg-config)
```
- Emulator: `emulator -avd pad_avd -no-window -no-audio -gpu off -no-snapshot`
(google_apis x86_64, Android 15). **`-gpu off -no-snapshot` matters** —
swiftshader_indirect + snapshot crashed the emulator once during the eval.
If it won't start, the host key lost its wireless pairing — see the pairing
procedure in `~/.pi/agent/AGENTS.md` (phone) / emulator docs (this AVD).
- APK lands at `target/<debug|release>/apk/gallery.apk`.
- Release signing uses the debug keystore
(`CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore`,
`CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android`) — fine for personal use;
for Play, generate a real keystore and point the env vars at it.
- `cargo apk2` quirk: `--target` (not `-t`). `--lib` builds the cdylib.
- Grant "All files access" to the app on the emulator after install:
`adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION`
(or Settings UI). `adb install -r` keeps the grant.
- **Emulator test data (M3)**: the AVD's `/Pictures/gallery-test/` has ~5k
*fake* JPEG stubs (zlib garbage — they scan fine but fail to decode, good
for volume tests). Real, decodable test JPEGs live in
`/Pictures/realtest/` (150, generated with PIL). They were `touch`-ed to a
future mtime so they sort to the TOP of the grid — when reasoning about
"which cells should have images", remember only cells 0–149 are real;
everything below is stubs and correctly shows shimmer. **`/Pictures/realtest_extra/`
holds 700 more real, decodable JPEGs (PIL, 1200–4032 px, mtime 2031 so they
sort to the very top, above the 150 in `realtest`) — added for the
scroll-back backlog repro; the real region is now ~850 files (283 rows).
`adb push` of a directory nests a subfolder (push files individually or
`mv` up); `adb shell touch -d '…'` works for setting mtimes. The thumb cache is at
`/sdcard/Android/data/wow.gmp.gallery/files/thumbs` (wipe it to force
re-decode).
- Perf measurement: `adb shell perfetto` / `adb shell dumpsys gfxinfo wow.gmp.gallery`,
`systrace`-style capture via Perfetto trace shell. Cold start:
`adb shell am start -W`.
## 4. Architecture
The UI framework is only the display layer. The performance lives in the
thumbnail engine, and it is framework-agnostic:
```
┌─ metadata ─────────────────────────────────────────────────────────┐
│ sqlite (rusqlite, bundled): path, mtime, size, w, h, orientation, │
│ thumb_path, date_taken — one background scan pass │
└──────────────┬─────────────────────────────────────────────────────┘
│ model → Slint (list of visible-range items)
┌─ thumbnail pool (tokio-free: N std::threads, N ≈ cores/2) ─────────┐
│ per job: token cancellation (AtomicU64 gen counter) + priority │
│ 1. disk cache hit? .thumbs/{sha1(path|mtime|size)}.jpg → done │
│ 2. decode AT TARGET SIZE (never full-size for the grid): │
│ JPEG: zune-jpeg "turbo" (decode+downscale in ONE pass, │
│ pure Rust, actively maintained) or turbojpeg (SIMD) │
│ HEIC: imagepipe (0.5, 2026-08) — VERIFY Android NDK build │
│ PNG: image/png │
│ 3. apply EXIF orientation (once, here — never per frame) │
│ 4. re-encode small JPEG → disk cache │
│ 5. channel → UI: (id, slint::Image) │
│ prefetch: decode visible ± 1 row; cancel items scrolled out │
└────────────────────────────────────────────────────────────────────┘
┌─ UI (Slint) ───────────────────────────────────────────────────────┐
│ grid screen: ListView (vertical) → row of N square Image cells │
│ viewer screen: full-size async decode (once, cached) + │
│ ScaleRotateGestureHandler pinch + 1-finger pan │
└────────────────────────────────────────────────────────────────────┘
```
Module layout:
```
src/
lib.rs # android_main + platform init + scan/UI glue (M1/M2)
main.rs # desktop entry [exists]
main.slint # screens (split into ui/ components at M3) [exists]
db.rs # sqlite schema + incremental scan apply [done M1]
scan.rs # directory walk, image detection, walk_stream [done M1]
thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
viewer.rs # full-size load + transform state (M4)
perm.rs # storage permission check/request (M5)
```
Crate choices (all verified current on crates.io, 2026-10):
| crate | version | why |
|---|---|---|
| `slint` / `slint-build` | 1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
| `rusqlite` | 0.41, `bundled` feature | SQLite without system deps; cross-compiles to Android |
| `turbojpeg` | 1.5.1, `cmake` feature | libjpeg-turbo DCT-level shrink decode (1/2,1/4,1/8) — 48 MP at 1/8 peaks ~2.3 MB vs 137 MB full decode. Vendors + cross-builds libjpeg-turbo via CMake/NDK (verified). Chosen over `libvips` (same DCT shrink mechanism, +6–10 MB of .so files, LGPL, 4.3k downloads) and over `zune-jpeg` (no DCT shrink = full-size decode for 12 MP photos). |
| `image` | 0.25, `default-features=false, png` | PNG thumbs + `imageops::resize` final fit |
| `kamadak-exif` | 0.6 | EXIF orientation tag (all 8 values applied as pixel maps); `experimental::Writer` used only in tests |
| `sha1` | 0.11 | cache keys |
| `imagepipe` | — (deferred) | HEIC: the real device has **0 HEIC** files (9109 JPEG, 27 PNG), so HEIC support is deferred; `image` can gain `heic` or libvips can replace the JPEG path later — the engine API is decoder-agnostic |
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side.
Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in
the in-memory LRU until memory pressure.
**The five failure modes the current (unknown) app likely has** — this design
eliminates all five: (1) full-size decode for grid cells, (2) no disk
thumbnail cache, (3) no list virtualization, (4) decode on UI thread without
cancellation, (5) EXIF rotation applied per frame.
## 5. UI plan (Slint patterns)
- **Grid**: `ListView` is the only virtualized std-widget (confirmed in
source docs: "Only visible rows are instantiated"). Grid = vertical
`ListView`, each row a horizontal layout of square `Image` cells
(columns ≈ `floor(window-width / cell)`; recompute on resize).
Cells bind `image <=> model.image` — when the pool delivers a thumbnail,
only that cell invalidates (retained mode; no repaint calls to remember).
- **Placeholder→image**: cell shows a `Rectangle` (shimmer optional, M6)
until its `image` is set.
- **Image cache**: Slint's built-in path-based LRU is 5 MB (`CLruCache` in
`internal/core/graphics/image/cache.rs`) — too small and the wrong layer
for us; our cache lives in Rust (disk + compressed-bytes LRU).
No mipmaps in the Skia renderer — irrelevant since we pre-downsample.
- **Viewer** (DONE in M4): pinch is the `ScaleRotateGestureHandler`
element — the 1.18 CORE synthesizes `PinchGesture` from raw two-finger
touches on every backend (the second finger never reaches a `TouchArea`;
see §6.15/16). Single-finger pan + double-tap-to-zoom are a small state
machine in `src/viewer.rs` driven by `TouchArea::pointer-event`. Crib:
`slint-src/examples/native-gestures` (source at
`~/prog/android/ui/slint-src`, tag v1.18.1).
- **Screens**: single `Window` with a stacked layout (grid + viewer overlay)
or a `Navigator`-style state property; keep it simple until M4.
- **Safe area**: always pad with `root.safe-area-insets.{top,bottom}` (done
in the scaffold).
- **Animation**: use built-in `animate` statements (syntax: `animate <prop>
{ duration: ...; easing: ...; }` — it is a *statement*, not an element).
Slide/fade for the viewer is a one-liner.
## 6. Slint 1.18 API gotchas (all hit firsthand in the eval)
1. `animate` is a statement (`animate width { duration: 300ms; }`), not an
`Animation {}` element.
2. Text input widget is `LineEdit`, not `TextField`.
3. Markdown interpolation: `text: "Count: \{count}"` (backslash-brace).
4. `length` is the unit type for lengths (`property <length>`, not
`logical<length>` in the 1.18 Rust API).
5. No Rust macros/closures inside `.slint` callbacks — callbacks call global
functions or invoke Rust via `@global` / imported callbacks.
6. 1.18 ↔ 1.19 **docs drift** — when reading docs online, check which
version; the local source at `~/prog/android/ui/slint-src` (tag v1.18.1)
is ground truth for the pinned version. `git grep` beats the docs.
7. `slint::android::init_with_event_listener(app, |event: &PollEvent| ...)`
re-exports the whole `android-activity` crate as
`slint::android::android_activity`; `AndroidApp` is also re-exported at
`slint::android::AndroidApp`.
8. `slint::Image` runtime construction: `Image::from_rgba8_premultiplied`
(also `from_array`); assign to an `image` property to update a cell.
9. Desktop Linux: needs `libfontconfig1` + `pkg-config` (pure Rust on macOS).
10. Retained-mode redraw: changing a property re-renders only affected
elements — design bindings so thumbnail delivery touches one property.
11. **`in property` is NOT auto-bound to a same-named `for` variable.** In
`for cell[c] in row.cells: CellView { ... }` you must write
`CellView { cell: cell; ... }` — otherwise the component's `in property
<Cell> cell` stays at its default (empty) and the cell renders blank
while *looking* correct. (This silently broke M3 for an hour.)
12. **Updating a nested model in place does NOT reach a ListView row that
was recycled while off-screen.** In-place `inner.set_row_data(col, …)`
updates only the currently-live `CellView`s; a row whose `GridRow`
instance was recycled keeps showing stale cells until the next scroll.
The reliable path is to replace the WHOLE outer row:
`outer.set_row_data(row, Item { cells: ModelRc::new(VecModel::from(…)) })`
— the outer `row_changed` makes the ListView re-bind `row-data` and the
fresh nested read picks up the new state. Batch cell changes per row and
do ≤ ~16 row-replacements per 100 ms tick (see `apply_row_batch`), or a
fast scroll mass-updates and ANRs.
13. `ListView` has no `spacing`/`gap` property in 1.18 — bake inter-row
rhythm into the item height (we add a 4 px bottom margin inside
`GridRow`; `ROW_SPACING` in lib.rs must match).
14. **`TouchArea` grabs EVERY press (mouse and touch) — `input_event` on
`Pressed` unconditionally returns `GrabMouse` (internal/core/items/
input_items.rs), which ABORTS the sibling traversal**
(`send_mouse_event_to_item` → `VisitChildrenResult::abort`). Siblings
are visited FRONT-TO-BACK = last-declared first (`TraversalOrder::
FrontToBack` in item_tree.rs iterates `count-1 … 0`), so the TOPMOST
(last-declared) TouchArea at a position wins the press, and any
earlier-declared TouchArea under it never sees the event. The viewer's
close button therefore must be declared AFTER the full-surface
TouchArea (M4 — this cost a session of debugging).
15. **The second finger NEVER reaches a `TouchArea`.** The CORE
(not the backend) owns two-finger semantics: `TouchState::process`
(internal/core/input.rs) turns per-finger `InternalEvent::Touch` into
mouse events, and when a second finger lands it synthesizes a Release
for the first finger and, after an 8 px threshold, emits
`PinchGesture`/`RotationGesture` mouse events — on EVERY backend,
including Android (the android-activity backend does deliver the
per-finger `Touch` events; the core converts them). Consequences:
`TouchArea::pointer-event` (1.18's `touch-finger-id`) only ever sees
ONE finger; manual two-finger math is dead code. When a pinch finger
lifts, the core re-presses the survivor (a fresh `Pressed`), which
seamlessly continues as a single-finger pan.
16. **Pinch in 1.18 = the `ScaleRotateGestureHandler` element** (added in
1.18.0; declared in internal/compiler/builtin_elements.rs, NOT in any
.slint file — `git grep ScaleRotateGestureHandler` the compiler crate).
API: `in enabled`, `out active`, `out scale` (CUMULATIVE from 1.0 at
gesture start), `out rotation`, `out center` (Point, in the element's
coordinates), callbacks `started`/`updated`/`ended`/`cancelled`.
`@expands_to_parent_geometry`. While active it INTERCEPTS all
non-gesture events (its `input_event_filter_before_children`).
Correct usage: snapshot your transform at `started`, apply
`snapshot · scale` about `center` at each `updated` (never multiply
incrementally — `scale` is cumulative), commit at `ended` (the
properties read identity once inactive). See `src/viewer.rs` +
`src/main.slint` (Viewer) and upstream
`slint-src/examples/native-gestures`.
## 7. Permissions & platform integration (Android)
Declared in `Cargo.toml` (verified schema in cargo-apk2 1.5 / ndk-build2:
`uses_permission` is **top-level** under `[package.metadata.android]`, and
`Permission` supports only `name` + `max_sdk_version`):
`READ_MEDIA_IMAGES`, `READ_EXTERNAL_STORAGE` (maxSdk 32),
`MANAGE_EXTERNAL_STORAGE`.
Runtime flow (M5), using the `ANDROID_APP` clone stashed in `src/lib.rs`:
1. **MANAGE_EXTERNAL_STORAGE** (primary path, same as passgo): no runtime
dialog exists — check `Environment.isExternalStorageManager()` via JNI,
if false launch `Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION` via
`ANDROID_APP.get().run_on_java_main_thread(...)` (attach `JNIEnv` from
`vm_as_ptr()`; Activity from `activity_as_ptr()`), then **re-check on
`MainEvent::Resume`** — the hook is already wired in `src/lib.rs`
(TODO(M5) marker).
2. **READ_MEDIA_IMAGES / READ_EXTERNAL_STORAGE**: `requestPermissions` via
the same JNI route; the bare `NativeActivity` does not forward
`onRequestPermissionsResult` to native, so either (a) poll
`checkSelfPermission` after requesting (good enough), or (b) ship a custom
`NativeActivity` subclass via cargo-apk2 `java_sources = "android/java"`
(manifest `activity name` points at it) — the proper callback, reusable
pattern from the eval.
3. Facts that constrain design (verified in android-activity 0.6.1 source):
no `NewIntent` event; `poll_events` may only run on the `android_main`
thread (Slint owns it — don't add a second consumer); `AndroidApp` is
`Clone` and a clone works from any thread (`run_on_java_main_thread`,
`vm_as_ptr`, `activity_as_ptr`).
4. **Source discovery** for photos: start with a plain directory scan of
`/storage/emulated/0/Pictures` + `DCIM` (works with all-files access,
emulator-friendly, and it's what makes the app testable without
MediaStore). MediaStore integration (API-aware, scoped to
`READ_MEDIA_IMAGES`) is a later refinement if directory scanning proves
insufficient.
## 8. Performance targets & measurement
Targets (emulator is NOT representative for absolute decode times — validate
on a real device with the aarch64 build):
- Grid: 60 fps scrolling at 5k+ items; zero full-size decodes on the hot path.
- First frame: grid of placeholders < 100 ms after launch (metadata from
sqlite, images async).
- Thumbnail decode (grid size): < 5 ms off-thread per JPEG (measure with
`std::time::Instant` logs; zune-jpeg turbo should be well under).
- Steady-state memory: in-memory thumb cache bounded (~64–128 MB compressed
bytes on a phone) + Slint's texture memory for visible tiles only.
- Cold start (emulator, release): compare against the eval baseline
(Slint sample ≈ 1–2 s; gallery must not regress > 0.5 s).
**Scroll-FPS measurement (real device, verified 2026-10-27):**
`dumpsys gfxinfo wow.gmp.gallery` reports **0 frames** for Slint — the
NativeActivity window renders via its own EGL surface and is not tracked by
gfxinfo on Android 15 (works for normal View apps). Use SurfaceFlinger's
per-layer latency instead:
```bash
# find the layer name:
adb shell dumpsys SurfaceFlinger | grep "VRI-wow.gmp.gallery"
# while scrolling (swipes via `input swipe`): capture 1 s of frame history
adb shell "dumpsys SurfaceFlinger --latency 'VRI-wow.gmp.gallery/android.app.NativeActivity#<id>'"
# each line: latch_time display_time … — display_time is the vsync grid.
# fps = 1e6 / avg(display gap); jank = display gaps > ~25 ms; the latch
# column shows frame PRODUCTION timing (p95 ~17 ms = on time at 60 Hz).
```
Result (Pixel 9 Pro, release aarch64, 8,577 photos, 500 px thumbs):
sustained **60.0 fps, 0 display hitches** while scrolling, both cold cache
(production p95 21–25 ms) and warm cache (production avg 16.6 ms). The
display presents at a 60 Hz vsync grid (Slint requests Default framerate;
120 Hz is a later polish item, not an M3 acceptance criterion).
Note: the shimmer `animation-tick()` keeps frames flowing even at rest,
so latency history is continuous — filter by swiping if you need
scroll-only frames.
Test data (M2): generate a synthetic set on the device — a small Rust `bin`
or Python script that writes N=5,000 JPEGs of varied sizes/orientations into
`/storage/emulated/0/Pictures/gallery-test/` (include a few HEICs if
imagepipe works; include rotated EXIF). Reuse it for every perf run.
Measure with: `adb shell am start -W` (cold start),
`dumpsys gfxinfo wow.gmp.gallery framestats`, Perfetto trace (GPU + main +
pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
## 8.5 Jank hunt (IN PROGRESS — phone is the acceptance target)
Symptom: user reports janky grid scroll (moderate drag, no fling) on the
phone (Pixel 9 Pro, 120 Hz). Emulator repro via programmatic scroll hook.
### What is ruled out (all measured)
- **App tick cost**: 7–34 µs/tick, even inside 28–39 ms gaps. Not the tick.
- **Logd backpressure**: fixed — `nblog` (bounded mpsc + drain thread,
nanosecond `try_send` from UI). Idle CPU 71% → 5%.
- **60/120 Hz display zone flap**: SystemUI (uid 10156) was asserting a 60 Hz
override every minute; pinned with `settings put system min_refresh_rate
120` (reversible: `settings delete system min_refresh_rate`).
- **Shimmer animation**: `animation-tick()` in a per-cell binding evaluated
O(ALL 8577 cells) `sin()` every frame (70.9% CPU idle). Now a static
placeholder; proper O(visible) shimmer is M6 work.
- **Whole-row model replacement**: now in-place `cells.set_row_data` (one
property change; texture upload only when pixels change).
- **Texture upload CPU**: APLY 1.2–2.9 ms/cell (480 KB) — under budget, and
too cheap to explain 28–39 ms (would need 12–17 MB/s on a Mali).
### Measured facts
- Emulator, programmatic scroll (`/sdcard/Download/.gallery_cmd` →
`scroll <px/ms> <ms>`), real textures: 28–146 ms cadence. During every
stall the UI thread is **100 % on-CPU** (jsample: state R, wchan 0) —
Slint's render pass is CPU-bound.
- Emulator, 300 items instead of 5852: **equally janky** → scene/item count
is NOT the cost driver.
- Emulator, grey 1×1 images (`.gallery_grey` touch file): steady ~16.6 ms
(60 Hz pacing), worst 44 ms → the cost is **rasterizing ~30 live textured
cells at 1080×2400 through the emulator's software GL (llvmpipe)**.
The emulator has a hard rendering ceiling and can never scroll this
smoothly — it validates LOGIC (invariants, paint coverage), not feel.
- Phone: 64 % of frames on the 8.3 ms (120 Hz) grid; ~4 % take 28–39 ms of
non-tick work with d≈0 (UI thread not in our code). Frequency ≈ every
170 ms — much faster than new cells enter the viewport (~1.7 s), so it is
NOT first-paint/upload timing. Suspects left: GPU/present contention
(Mali + SystemUI both at 120 Hz), Slint EGL/swap path, or an
event-loop/vsync interaction. Needs the phone.
### Tools / hooks
The hunt's file hooks (`.gallery_cmd`, `.gallery_*` knobs, SCRDBG/TICK/APLY
logs, jsamp sampler) were REMOVED in the 2026-10-28 cleanup — see §8.9.
`nblog` (non-blocking logger) and the debug-level coverage logs stayed.
### Outcome
The decisive A/B was not needed: stage timestamps inside the vendored
renderer isolated the stall to the Skia renderer's final synchronous
flush (Mali driver back-pressure). Fix + evidence: §8.9 and
`docs/BUGS/slint-skia-final-flush-jank.md`.
3. Compare against Fossify (installed on the phone, user says smooth):
`dumpsys SurfaceFlinger --latency` on both apps' layers during identical
scrolls.
4. **Priority fix already applied** (commit after this edit):
- `thumb-0..3` + `thumb-dispatch` demoted to nice +2 (they inherited the
UI's -10 and could preempt it during turbojpeg bursts).
- Slint's `slint vsync` Choreographer waker (created at nice +10 by
Android — the ONLY wakeup source for the main loop while animating,
see i-slint-backend-android-activity/vsync.rs) is raised to nice 0 by
a `prio-fix` thread in android_main (log: "slint vsync raised to nice
0"). A starved waker delays the main loop's wakeup by 3–4 frames at
120 Hz = 25–33 ms with zero tick cost — the measured phone signature.
Verified on emulator: nice holds at 0 while foreground; Android
re-nices backgrounded processes (expected, irrelevant).
- Verification on phone: check the two log lines, then
`for t in /proc/$(pidof wow.gmp.gallery)/task/*; do ...` and confirm
thumb-* at 2, slint vsync at 0, before/while the user scrolls.
5. Candidate code changes (after the A/B): GRID_TARGET 500→400 (one-time
cache invalidation — don't do it before knowing the culprit), 1 painted
cell/tick instead of 2.
## 9. Milestones
- **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs,
runs on emulator; permission-aware init + Resume hook; build script;
manifest with permissions; this document.
- **M1 — metadata (DONE, 2026-10-27)**: `db.rs` + `scan.rs`; background scan
thread → sqlite → `VecModel` in a `ListView` (paths as text). Acceptance
verified on emulator: **5002 files in 153 ms**; relaunch `0+ 0~ 0-` in 2 ms;
mtime-touch → `1~`, delete → `1-`. Status line shows `N files (a+ u~ d–) in X ms`.
List paint is instant on relaunch: the DB list is shown first
("N files (cached)"), the background walk then runs and refreshes
("Updating… N"). First launch streams results into the list in 512-file
batches while scanning — the UI never waits for the full DB build
(`walk_stream`). Hidden dirs (`.thumbnails`, …) are skipped.
Real-device numbers (Pixel-class, 8.5k photos): cold scan ~4.2–5.9 s
(filesystem metadata cost — the walk is the bottleneck, not sqlite),
relaunch paint instant, rescan ~4.2 s. Walk parallelism is an M2/M7
optimization candidate.
Gotchas hit (also see §6/§12):
- `slint::Result` / `slint::WindowHandle` **do not exist in 1.18.1** as
published on crates.io — desktop bin is `fn main() -> Result<(),
slint::PlatformError>` + `use slint::ComponentHandle;`. (The M0 desktop
bin never actually compiled; only the Android lib was built. The eval
sample `slint_app` is broken the same way.)
- `.slint` modulo is `Math.mod(x, y)`, not `%`.
- ListView rows: `for path[index] in root.items: …` (`index` is not in
scope unless named; `parent` = the ListView).
- `rusqlite(bundled)` needs `CC_<triple>` pointing at NDK clang for
Android — `scripts/build.sh` now exports them.
- Android DB path: `EXTERNAL_DATA_DIR` is **not set** by this
NativeActivity setup and `temp_dir()` = `/data/user/0` (unwritable).
Use `/storage/emulated/0/Android/data/<pkg>/files`, internal files dir
fallback (see `db::android_data_dir`).
- Emulator: grant all-files access once via
`adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow`; it
survives `adb install -r`.
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
or the app can't read them.
- **M2 — thumbnail engine (DONE, 2026-10-27)**: `images.rs` (turbojpeg
DCT-level shrink decode via 1/2/1/4/1/8 `ScalingFactor`, all 8 EXIF
orientations as uniform `(x,y)→(dx,dy)` pixel maps, `image::imageops::resize`
final fit, re-encode to disk cache at q85; PNG via `image`) + `thumb.rs`
(dispatcher thread round-robining to N=cores/2 workers clamped 2..8,
unbounded job queue, disk cache `thumbs/{sha1(path|mtime|size|target)}.jpg`
— target in the key so list-64px and grid-500px coexist; atomic
`.tmp`→rename; generation counter for cancellation; per-worker sqlite
connection writes `set_thumb` w/h/orientation/thumb_path) + `lib.rs`
(process-wide `AppState` in `OnceLock`, 10 Hz drain timer — `Timer` is
`!Send`, leaked with `std::mem::forget`, scroll-prefetch with 50-row
margin, `do_prefetch` flag so streaming scan batches don't re-prefetch) +
`main.slint` (rows carry `image` now).
*Accept, verified:* 12 unit tests + 5k synthetic engine test (cold fill,
100% cache warm refill, generation cancel). Release aarch64 APK 9.8 MB.
**Real device (Pixel 9 Pro, 8577 photos):** cold scan ~5 s with list
streaming; warm relaunch paints the cached list instantly; thumbnails
render in every visible row (screenshot-verified); relaunch cache hits
~100% (255–258 of first 256 jobs); 2 WebP files misnamed `.jpg` fail
gracefully (logged, row stays empty). Worker log cadence: every 256 jobs.
*Known transient (unreproducible): the very first aarch64-release install
rendered a pure-black screen (no drain-timer "painted" logs) until the
next launch; it did **not** reproduce on the emulator (screen-off launch +
wake) nor across repeated device launches. Suspected screen-lock / display
state race on first GL frame. If it recurs: force a window refresh on
Resume (see §10.7).*
*Build gotchas hit (also in build.sh comments):*
- `ANDROID_PLATFORM=android-26` (NDK CMake toolchain) **conflicts** with
the `android-build` crate (Slint's Java compile) unless `ANDROID_JAR`
is set explicitly to `platforms/android-35/android.jar`.
- x86_64 host builds of turbojpeg-sys need `nasm` (`~/.local/bin/nasm`;
aarch64 does not).
- `ADB_SERIAL=…` env var picks the device in build.sh (phone + emulator
are often both attached).
- build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`,
`aarch64`; second arg `no-install` to skip install.
- **M3 — grid UI (DONE 2026-10-27)**: 3-column `ListView` grid (`GridRow` =
one list item holding up to 3 square `CellView`s), shimmer placeholder via
a time-driven `animation-tick()` expression, scroll-driven prefetch
(`scrolled → on_scroll`), memory-bound retention (cells drop to shimmer
when they leave the active range; disk cache repaints them in ~10 ms).
Key fixes along the way (see §6.11–13): `in property` must be assigned
explicitly in a `for`; cell updates must replace the whole outer row
(`apply_row_batch`) and are capped at 16 rows/tick to avoid ANR on fast
scroll; `on_scroll` only re-anchors the active range + submits prefetch
(no model mutation), all mutation is in the 10 Hz drain timer.
**Thumbnail loading: viewport reconciliation + worker-level priority
(rewritten 2026-10-27 after two field-reported bugs).** Design doc is the
header comment of `src/lib.rs`; engine in `src/thumb.rs`. The invariants:
1. *The viewport is the only source of truth.* `on_scroll` only records the
viewport range (zero work). The 10 Hz tick is the sole authority AND the
sole submitter: CONSUME finished results (paint viewport ones, capped),
EVICT far pixels (hard cap `MAX_PAINTED=120`), FILL the viewport
(center-first), PRUNE queued jobs outside the retention band. After each
tick: every viewport cell is PAINTED, IN-FLIGHT, or FAILED — restored,
not maintained, so no scroll pattern can leave a visible cell forgotten
(the original bug: re-submission only happened in `on_scroll`, so when
scrolling STOPPED, forgotten cells stayed empty until the next small
move). `debug_assert`ed every tick.
2. *The viewport is first at EVERY queue level.* The central queue
(`Mutex<VecDeque>` + `Condvar`, prepend-on-submit) is drained as
priority-ordered bursts and PREPENDED to the per-worker queues (also
`Mutex<VecDeque>` + `Condvar`, NOT mpsc — mpsc is FIFO-only). Each
worker queue is capped (96 ≈ two screens): overflow is dropped from the
TAIL (oldest transit work) with a `skipped: true` result; the UI clears
the in-flight marker, the cell is NOT failed, re-entry re-submits it
(disk-cache hit). A burst ≤ viewport (~45) < cap, so fresh viewport work
is never dropped. `in_flight` accounting: +1 at submit, −1 at finish OR
at cap-drop; the UI's `inflight` map (path→time) is the dedupe key and
is cleared on every result (incl. `skipped`) — a dropped job whose
path has a newer queued job may be briefly re-submitted (one wasted
decode, self-cleans).
Regression tests: `thumb::tests::scroll_back_cache_hit_not_starved`
(central-level: 6.3 s FIFO starvation → 130 ms) and
`thumb::tests::fresh_batch_preempts_stale_worker_queue` (worker-level: a
fresh job submitted while a slow batch is queued must finish within the
first two results, never behind the whole old queue).
*Stress results (emulator x86_64 DEBUG build — slowest plausible hardware —
then release aarch64 on the Pixel 9 Pro):* fast up/down ×5 (the reported
repro): viewport fully covered 0–119 ms after stop (4/5 already covered at
stop); cold landings: 1.0–1.2 s = pure decode throughput (p rises
monotonically while stale jobs wait BEHIND, oldest growing 700→1900 ms
harmlessly); chaos ×6 + 25 s idle soak: covered before every stop, max
oldest in-flight job 217 ms (pre-fix: 8,055 ms), max queue depth 45 (one
viewport), 0 invariant violations in ~1,450 ticks, 0 panics, idle
converges to 0 queued. Phone smoke: 99.98% cache hits during 8k-cell
stress scroll, 0 panics/ANRs, one 541 ms transient stall alarm (cold
decode burst).
*Verified (emulator, 150 real JPEGs + 5k stubs): first paint 0.6 s cold;
warm relaunch from disk cache; 0 ANRs through 16 fast swipes; backlog
drains to 0 when idle. Verified on Pixel 9 Pro (release aarch64, 8,577
photos): first screen ~0.7 s cold at 500 px; scroll = sustained 60 fps
with 0 display hitches (cold and warm cache) — see §8 for the
measurement method (gfxinfo does NOT track Slint's window).*
- **M4 — viewer (DONE, 2026-10-29)**: tap cell → full decode (turbojpeg
DCT shrink, 2×-screen cap bounded by GPU max texture, EXIF orientation
applied) + full-screen overlay; pinch zoom (Slint's
`ScaleRotateGestureHandler` — see §6.15/16; transform math is
`zoom_at`/`Input::pinch` in `src/viewer.rs`); single-finger pan +
double-tap zoom (2.5× fit at tap point; again → reset to fit);
back key (vendor patch: `Keycode::Back` → `CloseRequested`, the app's
`on_close_requested` closes the viewer first) + × button; error text on
decode failure. Verified on the emulator (pad_avd 1080×2400): all
gestures incl. REAL two-finger pinch (sendevent MT-B — recipe below);
phone: grid + viewer open + 4080×3072 decode OK (pinch not injectable
on the user build — SELinux blocks /dev/input writes and `input`
has no multi-pointer; trust the unit tests + emulator).
**Emulator two-finger injection recipe** (userdebug + `adb root`;
active touch device = the one under `Touch Input Mapper (mode - DIRECT)`
in `dumpsys input` — on pad_avd that's event2, NOT the one `getevent`
prints first; per-axis raw→px: X×W/32767, Y×H/32767): MT-B with explicit
`ABS_MT_SLOT`(47) per finger, `ABS_MT_TRACKING_ID`(57) re-emitted on
every move frame (values ≥0 = present, -1 = lifted), one SYN(0 0 0) per
frame, BTN_TOUCH(1 330) on first-down/last-up. Each `sendevent` is a
fork (~20 ms), so `input tap x y; input tap x y` in ONE shell is the
way to make a ≤300 ms double-tap.
Gotchas (also in §6.14–16): the close button MUST be declared after the
full-surface TouchArea (TouchArea grabs + aborts sibling traversal);
`ScaleRotateGestureHandler` is declared in the compiler crate, not any
.slint file; Slint logical px = physical px / (dpi/160) on Android.
- **M5 — permissions**: implement `perm.rs` per §7; launch flow when
un-granted (settings card → open Settings → recheck on Resume); test the
exact passgo-style flow on a fresh install.
- **M6 — polish**: dark theme, animations (slide/fade), search/filter
(name/date), sort; double-tap zoom; battery-friendly (pool idles, no
busy repaint — Slint is retained, verify with gfxinfo "total frames").
- **M7 — perf & release**: real-device (aarch64) pass; Perfetto; memory
bounds; release APK size check (expect ~8–9 MB like the sample);
consider the 1.19 upgrade (verify pointer-scroll fix first).
## 10. Open questions & risks
1. **HEIC (RESOLVED for now)**: the real device has 0 HEIC files (9109
JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
`images.rs` dispatch (libheif-rs / `image` heic feature / libvips);
the engine API is decoder-agnostic.
2. **Slint pointer-scroll `todo!()` panic** (open issue in the Android
backend at eval time): could fire on trackpad/touch scroll. Test scrolling
on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19
when available.
3. **Max texture size**: full-screen decode of 12 MP+ images (e.g.
8192 px) may exceed GPU max texture size on some devices — verify on
M4; fall back to a 2×-max downscale for display.
4. **Slint license**: royalty-free tier covers proprietary mobile/desktop/
web; embedded devices excluded — irrelevant for us, but the repo README
should state the chosen tier (royalty-free) once the app ships.
5. **Docs drift**: trust the pinned source (`~/prog/android/ui/slint-src`
@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade.
6. **rusqlite `bundled`** compiles SQLite from source in every target
(adds ~30–60 s to clean builds) — acceptable; revisit if it hurts.
7. **Transient black screen (M2 first launch, unreproducible)**: one
aarch64-release launch rendered pure black with a dead drain timer until
relaunch; not reproduced on emulator or device since. Candidate fix if it
recurs: in the `init_with_event_listener` Resume handler, call
`window.request_redraw()` / check `window.size()` (a 0×0 or stale surface
after a screen-off start would match the symptoms). Don't add it
speculatively.
## 11. Pointers
- Framework eval (full report + raw notes + both sample apps + screenshots):
`~/prog/android/ui/` — `REPORT.md`, `NOTES.md`, `slint_app/`, `eframe_app/`,
`slint-src/` (v1.18.1), `egui-src/` (0.37.0).
- Slint docs (version-sensitive!): https://slint.dev/docs — Android guide:
`docs/guide/platforms/mobile/android.mdx` in the source tree.
- `examples/native-gestures`, `demos/` in `slint-src` — reference code for
gestures, lists, images.
- passgo (existing Go app using the same all-files-access pattern):
`~/prog/go/passgo` — its `scripts/build.sh` and manifest handling are a
proven reference for the permission UX flow.
- Emulator AVD: `pad_avd` (google_apis x86_64, API 35); start command in §3.
- Gitea: push target `git+ssh://gitea@git.wow.st/gmp/gallery.git`
(repo must exist on the server first, like the other gmp/* repos).
### 8.7 GPU-resident thumbnails (jank fix, 2026-10-28)
Root cause of the perceived scroll jank (measured with the per-frame
`set_rendering_notifier` hook, `FR` lines in `.gallery_perf.log`): every
**first present** of a new `Image` uploaded its pixels inside the render
frame — Slint's Skia backend has no RGB888 path (byte-by-byte conversion
on the UI thread) and no API to pre-upload; measured 11–30 ms per 400 px
texture = 1.3 visible hitches/s at 120 Hz. Grey-image A/B: 20 hitches/s →
1. Not a frame-rate, input, or layout issue; Fossify (RenderThread
pipelining) doesn't have this cost.
Fix (no Slint patch, no re-architecture): the thumbnail is uploaded to
Slint's own wgpu device (captured from `GraphicsAPI::WGPU30` at
`RenderingSetup`, `src/gputex.rs`) via `queue.write_texture` (async, no
fence wait) BEFORE the presenting frame, so first present costs ~0.
`Image::try_from(texture)` needs `Rgba8Unorm` +
`TEXTURE_BINDING|RENDER_ATTACHMENT` (`unstable-wgpu-30` slint feature;
wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to
`from_rgba8` CPU path when the device isn't captured yet.
Result (Pixel 9 Pro, 120 Hz, 400 px): **zero mid-scroll renders > 4.4 ms**
(median 1.9 ms, p95 2.9 ms); only post-idle first-frames are expensive
(invisible — screen static).
**Revised 2026-10-28 (recreation bug, see
docs/BUGS/recreation-stale-textures.md):** the upload now runs **on the
UI thread** (drain tick, between frames) instead of on the decode
workers. Workers deliver plain RGBA8 bytes; `gputex::upload_rgba` creates
the texture + `write_texture` on the device captured from the rendering
notifier — Slint's borrowed-resource contract (use the context the
notifier hands you). A long-lived worker holding a device was the source
of stale-texture panics across activity recreation; with render-thread
uploads a texture is always created on the device of the world that
renders it. The keep-alive thread (default-off anyway) was REMOVED with
the worker-side upload.
Perf file: `/sdcard/Download/.gallery_perf.log` (SCR/TICK/APLY/FR).
Clear it by restarting the app (writer holds the fd).
### 8.8 Jank-hunt round 2 + the keep-alive incident (2026-10-28)
Follow-up on "still seeing jank" (post-GPU-texture). Instrumentation:
in-app thread sampler (`src/jsamp.rs`, `/proc/self/task`, knob
`.gallery_jsamp`), per-core cpufreq, cgroup/PSI probes. Findings:
- **Cgroup CPU quota ruled out**: this device's unified hierarchy has the
`cpu` controller DISABLED (`cgroup.controllers` = `memory` only; no
`cpu.max` file) — no per-app bandwidth cap exists.
- **`/proc` facts**: utime/stime are USER_HZ ticks (1/100 s), so
per-thread CPU deltas are 10 ms-quantized. The app's SELinux context
DENIES `/proc/stat`, `/proc/pressure/cpu`, and the unified cgroup files
(even the app's own cgroup), but allows `/proc/self/task/*` and
cpufreq `scaling_cur_freq`. **A denied read in a tight loop is an ANR:
each denial is an audit event; ~100/s froze the whole phone.**
jsamp now probes readability once and disables denied features forever.
- **Real profile** (single verified 120 Hz swipe, 370 frames): steady
scroll = 1.5–2.5 ms frames, **zero spikes**. All 20–40 ms frames were
the first frame after an input pause (15–100 ms) — resume/warm-up
cost; ~820 MHz little-core clock during spikes vs 1.95 GHz between
(EAS downclock in the gap, not a cgroup/driver block). Swipe starts
after only 2 s pauses: zero spikes; only >10 s app idle pays the big
(43 ms) cold frame.
- **KEEP-ALIVE INCIDENT (phone freeze)**: the `gputex::Keepalive`
thread (16 ms buffer-copy submits on Slint's wgpu queue from a 5th
thread) made the display pipeline FREEZE on Mali G715 during a 14-min
session — phone UI hung for seconds, 51 in-flight decodes stalled ~6 s
(workers also `write_texture` on the same queue). System did not
reboot (system_server/surfaceflinger 5-day uptime), but the user
experience was "crashing my phone". **Root lesson: do not run
concurrent submitters on Slint's Mali queue.** Keep-alive is now
INACTIVE BY DEFAULT (debug knob `.gallery_keep`). The jsamp sampler
was REMOVED in the 2026-10-28 cleanup (see §8.9).
- Status (post jank-fix, §8.9): with the Skia final-flush removed, the
mid-scroll stalls are GONE (0 stalls, max 3.9–7.4 ms across 4 runs,
phone). Residual after the fix: scroll-START ramp frames (16–27 ms
in the first ~400 ms after >2 s idle, CPU-clock ramp on the 820 MHz
little cores) — revisit only if the user still notices them.
### 8.9 Scroll jank — root cause & fix (2026-10-28, CLOSED)
The mid-scroll 19–26 ms stalls (2–4 per run, at fixed content offsets,
Mali-only — emulator/SwiftShader never reproduced) are **fixed**.
**Root cause (proven by stage-timestamp profiling of the vendored
renderer):** the Slint Skia renderer's final synchronous
`GrDirectContext::flush()` at the end of every frame
(`render_components_to_canvas`) blocked the UI thread 19.2–20.4 ms
whenever the Mali driver queue was momentarily busy (texture uploads
landing, first-draw pipeline state). Steady-state cost of that call:
0.3–0.8 ms. SwiftShader's flush returns instantly → emulator never
reproduced it, and every CPU-side hypothesis failed.
Full evidence, per-stage tables, prior art and the upstream-filing
question: **`docs/BUGS/slint-skia-final-flush-jank.md`**.
**Fix (kept, per user decision):** `vendor/i-slint-renderer-skia`
(pinned 1.18.1, `[patch.crates-io]`) with that one flush removed.
It is redundant on the wgpu backend: `wgpu_30_surface::render` calls
`flush_and_submit` (Skia `submit()`) before `queue.present`, same
Vulkan queue, in-order. `vendor/` contains ONLY this crate; the rest
of the patch set is a single commented-out hunk with an explanation.
Re-apply that hunk when bumping Slint. Upstream NOT filed yet (we
cannot be 100% sure the flush isn't doing a job for non-wgpu
backends) — the bug doc is written so it can be filed later.
**What the profiling also settled:**
- Slint 1.18 DOES prune the item walk (~133–152 visited items, not
O(rows)). The earlier O(n)-walk diagnosis (and the windowed
Flickable that was built on it) was WRONG; the windowed Flickable
was reverted per user directive (use Slint's ListView — it is
smooth once the flush is gone).
- Stall frames had 0–3 new Skia images (0.1 ms each), 1 binding
re-evaluation, 0 ms partial-renderer: none of the usual suspects.
**Verification (phone, Mali G715, 8,580 photos):**
- slow scroll 5 s ×3: 0 stalls, max 3.6 / 5.8 / 3.9 ms
- fast fling 8× velocity: 0 stalls, max 7.4 ms, p50 1.0 ms
- display screenshot-verified clean
- budget at 120 Hz is 8.3 ms → 65–99 % headroom
**Cleanup done (2026-10-28):** all jank-hunt instrumentation stripped
(FR/SCR/TICK/APLYD/STG logging, `stg.rs`, `.gallery_*` knobs, jsamp
thread sampler, perf-file mirroring in nblog, DbgScroll). KEPT:
nblog non-blocking logger (logd backpressure is real), zero-dimension
decode guards (thumb.rs + gputex.rs), debug-level coverage logs.
(keep-alive REMOVED later the same day with the worker-side GPU upload —
see docs/BUGS/recreation-stale-textures.md.)
**BUG FIXED while cleaning:** the app launched into an unstoppable
fast scroll on the user's phone — a leftover `.gallery_cmd`
(`scroll -12 4000`) from the last A/B run: DbgScroll never deleted
the file, and a fresh process sees `cmd_mtime == None` → re-reads it
on every launch; while active it also overrode the user's swipes.
Fix: the debug-scroll hook (and all file knobs) are gone; the
leftover file was deleted from the device.
**Next: M5 permissions** — implement `perm.rs` per §7 (launch flow when
un-granted: settings card → open Settings → recheck on Resume); test the
passgo-style flow on a fresh install. (M4 viewer is DONE, 2026-10-29 —
see the M4 entry in §9 for the verification notes + emulator pinch
injection recipe.)