- MediaStore fast path: Kotlin's ContentObserver queries MediaStore
for images added in the last 120s and writes path|added|size lines
to .ms_recent, then wakes Rust via the proven zero-arg
nativeFsChange() JNI. Rust upserts those rows and emits
immediately — a camera photo appears in ~1s without waiting for
the ~10s FUSE re-read of a 10,000-file directory. (Direct
Kotlin->Rust JNI string passing and Rust->Java calls crash under
the emulator's x86_64->arm64 native bridge; file handoff plus
zero-arg JNI is the only reliable channel.)
- js-ready handshake: the first gallery-files emit fires ~30 ms
after process start, before the WebView's JS exists (Tauri events
are fire-and-forget, so it was lost). JS now signals
'gtauri-js-ready'; Rust re-emits the current state.
- Virtualized grid: the document is ONE spacer (full scroll height)
plus ONE band of recycled cells pinned by transform — ~80 DOM
nodes total for a 10,000-photo library. Launch cost no longer
scales with library size; the first screen renders as soon as
the WebView boots and the first thumbs arrive.
- 'Missing photos' report resolved: the full walk of the 9,961-file
Camera dir enumerates exactly 8,438 images with no guard firing;
the difference is videos, which a photo gallery correctly
excludes.
Root causes found via per-event tracing (trace.log) + heartbeats:
1. Decode backlog: single worker at ~300ms/photo could not keep up with
fast long scrolls — phone session ended with a 2400-deep queue
draining at ~3 cells/s (13+ min to catch up). That was the
'scroll far down and nothing loads no matter how long I wait' zone.
- 4 parallel decode workers (~12 photos/s)
- viewport-aware stale drops: prefetch jobs more than 60 rows behind
the viewport are dropped, not decoded (fleeing leaves no backlog)
- Q_CAP bounds the queue; JS 1.5s safety net re-requests
- queue peak in repro test: 42 (was 2405); all 850 decodable
emulator images now load (708 before)
2. Stale-anchor bug: sendAnchor was leading-edge throttled with no
trailing call — if a scroll ended inside the 150ms window the Rust
anchor stayed stale forever, and the worker then dropped fresh
URGENT jobs (tap → full read) as 'off-screen'. Fix: trailing
anchor send; and URGENT jobs are never stale-dropped (only
prefetch work is droppable).
3. Viewer latency: full-res read ran on Chromium's main thread
(wry shouldInterceptRequest is synchronous) — 30-50MB flash read
froze the whole WebView. Now: thumb shown instantly (mem cache,
~1ms), full read queued urgent and done off-main; full-ready event
swaps in the full-res (256MB full-res LRU makes re-opens instant).
Measured click→full-res: 4ms on emulator.
4. Scan: skip OS junk (._* AppleDouble forks, dotfiles) — the phone's
DCIM carried 72 of them, rendering as permanent gray cells.
5. Viewer for a corrupt file now shows 'image unavailable' instead of
'loading…' forever; grid retry timers skip far-off-screen cells.
Chromium owns scroll/compositor/lazy-load/image-cache; Rust owns
scan/SQLite/thumbnail-decode behind a gallery:// custom protocol.
Hard-won gotchas (see in-code comments):
- Android: no native custom-scheme support → wry rewrites the URL to
http://gallery.localhost/... (custom_protocol_workaround). The JS
MUST request that form; the handler receives the reverted
gallery://localhost/... and must strip the authority.
- tauri_build::build() in build.rs must run UNCONDITIONALLY (build
scripts run on the host: a cfg(target_os) gate silently disables it,
which skips the rustc-cfg=mobile emission → no mobile_entry_point →
.so with no JNI symbols → UnsatisfiedLinkError).
- build.rs must list frontend files for rerun-if-changed (a dir-level
directive only watches the dir mtime, not content edits) — the .so
bakes the frontend at compile time and cargo otherwise ships stale
UI with a 'successful' build.
- logcat drops ~75% of lines under load: use the file trace.
- fetch() is CORS-restricted (page origin http://tauri.localhost);
<img> is not — add ACAO:* anyway.
Emulator (5853 real files): grid renders, lazy thumbs via
gallery:// with on-disk cache (87 files 35-72KB), FR log clean at
60Hz, viewer opens full-res with compositor-only pan/zoom. The single
'broken' cell is a 17-byte fake test JPEG (correct behavior).