A second drag while the first was settling showed the new image briefly
at the wrong size: the commit copied the preview's src into vimg (an
async paint — one frame of old content in the new box) and openViewer
then reset vScale=1, so the promoted image rendered at thumb scale
until full-res landed.
Commit now swaps the ELEMENTS' roles instead of copying pixels: the
preview element already shows the incoming image at its own size and
scale, so finishCommit promotes it in place (geometry adopted from
the loaded preview, moved to 0, brought to front, old primary hidden
immediately). No src copy, no async paint, no re-layout — the settled
frame is the drag frame. P/S role variables replace the fixed
vimg/vimg2 references throughout the viewer.
openViewer(keepVisible) no longer resets the view state (the commit
already set it), and a full-res landing while a slide owns
base/vScale is deferred to the safety timer instead of jumping the
animation.
Verified on the emulator: mid second-drag the promoted image holds
its exact fit size (1081 px) in every captured frame (the bug showed
~288), both images visible on the strip, and the chained landing is
pixel-identical to two deliberate swipes.
A second swipe while the new image was still sliding in got dropped
(pointerdown bailed while the commit tween was in flight). Now a
touch — or arrow key — during a commit takes it over: the commit
finishes instantly (roles swap at the settle positions, same
dimension-adoption path as natural completion) and the input starts a
fresh gesture on the landed image, so rapid successive swipes chain
through multiple pictures. A plain tap during a commit simply settles
it early.
Verified on the emulator: two 100 ms flicks 120 ms apart (the second
lands mid-commit) end on the exact same image — pixel-identical — as
two deliberate slow swipes.
Flicks now continue the finger's motion instead of taking a fixed
200 ms: commit duration = remaining distance / release velocity
(15 % snappier), clamped 80-240 ms, ease-out. A full-speed flick
releasing past half the screen commits in ~80 ms (was 200). Slow
releases past the 25 % threshold still settle gently (180 ms
smoothstep; 150 ms for keyboard nav).
Verified on the emulator: a 100 ms flick is fully settled before
the first post-release screencap (~150 ms), with only a 5 px
edge artifact between consecutive frames (no residual motion).
Settling on a different-aspect neighbor (e.g. landscape → portrait)
stretched: vimg kept the OLD image's width/height styles while showing
the new thumbnail, so the new pixels rendered in the old box until
full-res landed and fitViewer() reset the dimensions — a visible
vertical stretch then snap-back.
At the commit swap, adopt the incoming image's dimensions from the
drag preview (nav already knows w/h/scale from its loaded thumb):
base/vScale/width/height/transform are all set consistently in the
same task as the src swap, so the first settled frame is correct.
Fallback: if the preview never loaded (keyboard nav / slow thumb),
use the plain openViewer path where the thumb round-trip sets the
dimensions before first paint.
Verified on the emulator with a 4:3 landscape + 3:4 portrait pair:
settled content box 1439x1921 (0.749 = exact 3:4) from the first
settled frame, unchanged after the full-res swap.
Fix: on release the old image vanished instantly and only the new one
slid in. Now both images live on the page strip for the whole commit:
vimg (outgoing) slides off-screen while vimg2 (incoming, the drag
preview) slides home, over a 200 ms smoothstep; only then do the
elements swap roles (vimg.src = the already-decoded preview image,
vimg2 hidden after the swap paints — no blank frame).
Details:
- navigate() returns whether it committed; at the library edges it
refuses and the drag snaps back (previously a past-the-edge drag
could leave the image stuck mid-strip).
- commit tweens are locked: pointer/dblclick input is ignored for the
~200 ms so a second gesture can't fight the animation.
- openViewer gains a keepVisible (promoted) path that skips the
hide+thumb round-trip; vFullSeq reset per open (safety-net counter
could previously exhaust after ~11 opens).
- removed the now-dead slideFrom slide-in mechanism (its purpose is
subsumed by the two-image commit).
Verified on the emulator: mid-commit frame shows both images
(outgoing sliding off left, incoming from right); snap-back slides
both back; edge drag right on the first image snaps home.
Watch de-duplication (per the plan review): FileObserver (inotify,
push) and MediaStore ContentObserver (URI push) are the ONLY change
detectors. Removed: the 100 ms recv_timeout tick, the fast_dir_sig
mtime-diff watcher, collect_watched_dirs, changed_dirs, dir_sig,
rescan_subtrees, and the 5-minute full-rescan timer thread.
watch_files now blocks on the push channel; on Android each wake
coalesces ~50 ms so a burst of N file events becomes one payload
batch, one SQLite connection, and ONE grid emit (verified on the
emulator: 20-file copy = 1-2 emits, 20-file delete = 1 emit,
single add still lands in ~75-95 ms).
FileObserver rewritten: recursive registration (Pictures/DCIM tree,
cap 5000 dirs) and self-extending — a CREATE/MOVED_TO on a directory
spawns a new observer for it, so new albums are watched too.
tif/tiff added to the image filter.
Dead code removed (the Tauri port never wired them): scan::walk,
walk_dir_nostats, walk_dir_into, mtime_ns, decode_jpeg (full-size),
images::thumb_png, db::set_thumb, db::list_entries, db::count,
db::delete_by_media_id; Thumb shrunk to {rgb,width,height} (the
grid is uniform squares; the viewer uses full-res). Tests now
compile (tempfile dev-dependency was missing — the test module
never built) and were fixed to match the current API.
Viewer: live swipe preview — while a horizontal drag is in flight at
fit scale, the picture being swiped in renders on the page strip
(second <img>, its grid thumb, a ~1 ms mem hit) and tracks the
finger, so there is no blank side. On commit the primary image takes
over from exactly where the preview was; on snap-back the preview
slides back with the tween. Verified on the emulator (two images
visible mid-drag, clean slide home).
Not re-verified: 120 Hz jank (per instruction — measure once when
settled). Desktop paths untouched (deferred).
Standard gallery swipe: at fit scale a horizontal drag follows the
finger 1:1 (rubber-banded past the page edge); on release, past 25% of
the width or a fast flick (>0.35 px/ms), the neighbor in the list
opens with a 150 ms ease-out slide-in from the drag direction.
Short drags snap back; vertical drags at fit are inert; zoomed drags
still pan and pinch still zooms. Arrow keys navigate on desktop.
Verified on the emulator: left swipes advance, right swipe returns to
the previous image pixel-identical, 80px drag and vertical drag do not
navigate.
Root causes found by reproducing each on the emulator (2.8k-file fixture
with mixed jpg/mp4):
1. walk_dir_nostats (incremental rescan of >2000-file dirs) had NO
image-extension filter: it re-enumerated every file, injecting all
~1,727 videos into the DB. This is what made the count swing
8,439 -> 10,166 and the grid full of blank cells (404 rows).
Now filters through is_image_path() like every other walk.
2. walk_dir aborted the ENTIRE walk on any per-file stat failure
(file deleted mid-walk), so an error during the walk meant the
walk was never applied and deletions were never detected.
Per-file stat errors are now skipped; only readdir/root errors
fail the walk (an errored walk is still not applied).
3. dir_sig stat'ed every entry of every root dir every watch tick:
10k+ stats/second on the phone = 10s+ of FUSE I/O per second =
the long delays. On Android the MediaStore URI payloads already
carry the exact change signal, so the mtime-diff machinery now
runs desktop-only. The Android watch tick is a single read_dir
of our own app dir (no-op when empty): 1s backstop, 30s-throttled
fallback full rescan for events with no payload, 5-min full pass.
4. TOCTOU: a delete consumed while a full rescan was mid-walk lost
to the walk's stale snapshot (walk re-added the row). RESCANNING
gate parks payloads during the walk; they apply after it, so
mid-walk deletions always win. Reproduced deterministically
(churn during the startup walk), fixed, re-verified.
Emulator stress (all passing): full-walk convergence, add +1s,
delete +1s, video add/delete = zero rows, 60-op churn converges to
exact disk count, 3x relaunch idempotency, startup-walk race
converges (1s normally, 5-min backstop when the OS scanner drops
burst events), no-blank-cells visual check, 0.00% dropped frames.
Phone verified: 10,166 -> 8,439 on first launch (all video phantoms
purged), add appears ~2s after the OS indexes it, delete same,
DB returns to 8,439 exactly. ContentObserver registration + onChange
logging added (it fired reliably once the stale instance was
replaced).
Watching is now exactly what the OS tells us, with no filesystem
scanning on the event path:
- MainActivity's ContentObserver receives the changed URI from
Android. It queries that ONE row (~ms, no debounce) and drops a
one-line payload: A|media_id|path|size (row exists) or
D|media_id (row gone). Per-event files (.ms_recent.N, never
overwritten) so a burst cannot clobber an unread event. A zero-arg
JNI call wakes Rust; a 1 s backstop also drains the queue, so a
lost wake self-heals in a second.
- Rust applies the payload directly: upsert with media_id stored, or
delete resolved by id (items row first, then a media_map table that
records id→path for EVERY add event — including rows created by a
filesystem walk, which doesn't know the id). Content changes reset
decode-failure state so a still-being-written photo retries.
- The disk is the truth: the payload size is ignored in favor of a
stat (the scanner can leave _size NULL).
- The full FS walk remains as startup baseline + 5-min reconciliation,
and the mtime-diff incremental rescan remains as a second
independent path. Verified on the emulator: add → grid in ~2 s
(incl. OS scanner), delete → grid in ~2 s via media_id, missed wake
drained in 1 s.
Truncation machinery REMOVED (per user: FUSE is reliable; the
'truncations' were counting errors and a stale-DB cross-check firing
on legitimate changes): the two-strike mass-delete guard, the
walked_max watermark, the layer-2 re-walk, the per-directory stale
cross-check, and the read_dir_full retries are all gone. The one
invariant that replaces them: a walk that hits ANY filesystem error
fails and is NOT applied (an error is not 'those files are gone').
The phone DB now converges to the exact disk count (8,439 = 8,399
Camera + 27 Screenshots + 11 Messaging + 2 Bluesky); the earlier
10,166/8,438 swings were real disk-state changes, not truncation.
Emulator display fixed: the AVD image ships with
debug.hwui.drawing_enabled=0 (every window renders black, HWUI
drawing disabled system-wide). 'adb shell setprop
debug.hwui.drawing_enabled 1' after each boot restores rendering —
it is a runtime prop, so it must be re-set on every cold boot.
- 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.
Live watching is now event-driven, no tree polling:
- Android: MainActivity registers a MediaStore ContentObserver
(Images, notifyForDescendants) and wakes the Rust watcher thread via
a JNI call (Java_wow_gmp_gallery_MainActivity_nativeFsChange) — a
single channel send, no main-thread work. Covers camera saves,
media-scanner indexing, other apps' indexed writes.
- Desktop: inotify/FSEvents via notify on the scan roots.
- On wake: coalesce + debounce, diff dir mtimes, rescan only the
changed subtrees. The 1 s recv timeout doubles as a cheap backstop
(raw out-of-band copies the scanner hasn't indexed yet); a 5-minute
full pass is the safety net. minSdk 30 (3-arg ContentObserver API).
Stale-listing truncation bug found and fixed (reproduced on the
emulator's 9p /sdcard; the phone's FUSE does the same): a readdir over
a large directory can return a SILENT early EOF — a stale cached
listing with NO error. The phone's 9961-file Camera dir truncated at
~8400 once and db::apply deleted ~1700 good rows. Three layers now:
1. scan::read_dir_full — retry whole-dir reads on mid-stream errors;
2. per-directory cross-check in walk_dir: if the enumeration is far
below the DB's count for that dir, re-read up to 3x keeping the
best listing; whole-walk re-check in rescan keeps the larger of two
walks;
3. db::apply mass-delete guard: never delete >20% of the DB in one
apply unless a previous walk already saw the low count (two-strike;
a meta.walked row tracks it). Genuine gradual deletions still flow
through (verified: a 4000-file mass move streams the grid down
progressively), and a second consecutive low walk still deletes.
Emulator reliability suite (all pass): 3x launch count stability,
add +1 / remove -1 in ~2 s, 20-file burst, DB recovery from damage,
stale-listing refusal (0 rows lost), two-strike delete, live restore,
idle 0.0% dropped frames, deep fling loads thumbnails. A/B vs ttimg
(no-backend CSS-scroll control): adb-swipe drop deltas are at the
input-injection artifact level, idle identical (0.0%).
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).
The backlog must never delay on-screen work. Urgent jobs (real
Chromium requests) are now stale-dropped too, not just prefetch:
a fast fling queues urgent work for every band it crosses, and all
of it except the final band is dead — it filled the queue cap and
made fresh on-screen requests wait for the JS retry timer.
Safe because the anchor now settles at the final scroll position
(trailing send), and on-screen cells have idx >= VPORT so they can
never be dropped. A pure LIFO would not fix this: stale jobs sit
at the stack bottom, never popped, still blocking the cap.
Emulator fling test: 309 stale urgent jobs purged, queue peak 28,
all 850 decodable images loaded.
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).