- Camera-photo latency: root cause was READ_MEDIA_IMAGES never
granted (granted=false on the phone), so the MediaStore fast-path
query silently returned nothing and photos only appeared via the
slow full FUSE walk. The app now requests the permission at
runtime (once, with a single retry on denial). The camera writes
through MediaStore at capture time, so the ContentObserver fires
immediately and the photo appears in ~1 s.
- Delete blank boxes: two JS flaws — (1) no img onerror handler, so a
cell that requested a thumb for an already-deleted file (404) sat
as an empty dark box forever; (2) recycled cells were matched by
slot index only, so after a deletion shifted the list a cell could
keep a stale id. Now: onerror drops stale ids (idSet membership) or
retries transient ones, and cells reassign on idx OR id change.
- Truncation guard (chicken-and-egg): the mass-delete suspicion
threshold was 20% but observed FUSE truncations lose 15-17%, which
sailed under it and poisoned the DB. Now 10%; a confirmed deletion
(two-strike passed) lowers the new walked_max watermark, a refused
one keeps it. Layer-2 re-walk compares against max(db, walked_max)
so a poisoned (shrunk) DB cannot silence truncation detection.
Emulator suite: healthy→truncated (all 3 layers, 0 loss),
poisoned-DB→truncated (layer 2 now fires), healthy walk self-heals.
- Verified on the phone: DB matches disk exactly (8,398 Camera +
27 screenshots + 11 messaging + 2 bluesky = 8,438); an earlier
10,166 count was a transient user-system state, not truncation.
- 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 cause of 'adds/removes not showing up': the watcher's file-COUNT
tree signature took 8-10 s per tick on the phone (FUSE readdir of the
8400-file Camera dir is ~1 ms/entry), so the 1 s poll was effectively
a 10 s walk, and the old 30 s forced FULL rescan (walk+stat of every
file, ~30 s) kept the watcher thread perpetually busy.
New design:
- 1 s tick stats DIRECTORY mtimes only (~1 ms). Verified on the phone:
Android's FUSE DOES update dir mtime on file add/remove (stat
before/after a copy showed it) — the earlier 'never fires' reading
was wrong; no files had actually been added in that trace window.
- On a changed dir: incremental rescan of ONLY that subtree. Small
dirs (<=2000 files) get a full walk with stat (adds, deletes AND
in-place edits). The huge Camera dir gets a readdir-only walk: new
files are stat'd, known files keep their DB mtime/size, deletions
are detected by set difference. Everything outside the changed
subtree keeps its DB metadata — cost is proportional to the change.
- Full rescan every 5 min as the safety net (in-place edits in the
huge dir, anything missed).
- DB: busy_timeout(10s) so the startup scan and watcher can transact
concurrently (WAL).
Measured on the phone: add to Pictures -> grid +1 in ~5 s; add to
Camera -> +1 in ~12 s; removals picked up on the next tick. Also
observed tracking a live 1700-photo user import (grid 8440 -> 10166
as the import progressed).
JS: event payload normalizer for thumb-ready/full-ready (object vs
JSON string across WebView builds), and js files/emit instrumentation
to compare send vs receive counts.
Pinch: the image now follows the hand (midpoint drift) and zooms
ABOUT the midpoint — the image point under the fingers stays put
while the scale changes. Per-frame delta math, clamped as before
(fit min, image-edge pan limits).
Live watching: new photos appear in the grid within ~2s of being
written. inotify does not work on Android's FUSE-backed external
storage, so the watcher polls a tree signature (dir mtimes only —
a few dozen stats/s) every second; on change (or a 30s safety-net
full pass) it debounces 600ms for the write burst, then runs the
incremental rescan (walk + DB diff + emit) off the main thread.
Supporting changes:
- Staged corruption: a decode failure no longer marks an id broken
immediately — 3 consecutive failures are required, and a success
clears the count. A photo still being written fails once or twice
and then decodes; genuine garbage gives up and stays gray.
- Grid rebuild preserves the viewport across a live rescan (pins to
the cell that was on top), so a new photo landing at the top does
not jump the scroll position.
Pan is now limited to the image bounds (|offset| <= (rendered -
viewport)/2 per axis), so the image can never be dragged off-screen
leaving blank space around it; on a letterboxed axis panning is
locked to centered. Pinch can no longer zoom out past fit. Clamping
lives in vApply() so pan, pinch and reset all share it; still
compositor-only (transform only).
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).