Commit Graph

13 Commits

Author SHA1 Message Date
314022cfb2 gtauri: implement 100% pure-push FileObserver in Kotlin (sub-50ms instant add/delete) 2026-11-03 11:02:40 -05:00
471d2faeab gtauri: zero-delay handling for Android MediaStore content watcher events (immediate push) 2026-11-03 09:09:34 -05:00
15a857ed7f gtauri: enable fast mtime-diff filesystem watcher on Android (adds & deletes reflected in ~1s) 2026-11-03 08:49:06 -05:00
10ec0b52cc gtauri: fix phantom rows, dead deletion detection, 5-min launch stall
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).
2026-11-02 12:37:42 -05:00
bb349dac82 gtauri: URI-driven MediaStore events; remove truncation machinery
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.
2026-11-02 11:09:29 -05:00
1bc1d6beb3 gtauri: fix camera-photo latency, delete blank boxes, truncation guard
- 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.
2026-11-02 08:01:13 -05:00
9e52dc0355 gtauri: MediaStore fast path + js-ready handshake + virtualized grid
- 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.
2026-11-02 00:02:48 -05:00
58b203e4fa gtauri: event-driven watching + defense against stale VFS dir listings
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%).
2026-11-01 20:24:01 -05:00
bd27a7ccc1 gtauri: fix live watching (incremental subtree rescans)
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.
2026-11-01 18:55:31 -05:00
312d87f15d gtauri: pinch zoom about pointer midpoint + live filesystem watching
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.
2026-11-01 18:02:09 -05:00
d990627200 gtauri: distance-from-viewport drop policy for ALL jobs
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.
2026-11-01 15:38:22 -05:00
02d1f097ac gtauri: fix deep-scroll stall + instant viewer
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.
2026-11-01 09:11:11 -05:00
gmp
e019a950b1 gtauri: Tauri v2 gallery harness WORKING on emulator
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).
2026-10-31 18:59:51 -04:00