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.
This commit is contained in:
Greg Pomerantz 2026-11-02 00:02:48 -05:00
parent ef884b3259
commit 9e52dc0355
6 changed files with 449 additions and 134 deletions

View File

@ -5,9 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Gallery</title>
<style>
/* Chromium owns: layout, compositor-driven scroll, lazy fetch+decode,
image cache/eviction, viewer compositing. Rust owns: scan, SQLite,
on-disk thumbnail cache behind gallery://. */
/* Chromium owns: layout, compositor-driven scroll, image cache,
viewer compositing. Rust owns: scan, SQLite, thumbnail decode and
the on-disk cache behind gallery://. */
/*
VIRTUALIZED GRID
The document contains ONE spacer div (full scroll height) and ONE
band of recycled cells (~2.5 screens + buffer). Total DOM nodes:
~80 for a 10,000-photo library — layout cost is independent of
library size, so launch renders the first screen as soon as the
WebView boots + the first thumbnails arrive.
*/
html, body {
margin: 0; padding: 0;
background: #0a0a0c;
@ -24,19 +32,22 @@
z-index: 10;
}
#grid {
#grid { position: relative; }
#spacer { /* scroll length driver — no content */ }
#band {
position: absolute; left: 0; right: 0; top: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
will-change: transform; /* compositor-positioned, zero layout */
}
.cell {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #1c1c22;
border-radius: 2px;
}
/* explicit 1:1 box via aspect-ratio → zero layout shift on arrival */
.cell img {
width: 100%; height: 100%;
object-fit: cover;
@ -79,7 +90,10 @@
</head>
<body>
<div id="status"><span id="stext">starting…</span></div>
<div id="grid"></div>
<div id="grid">
<div id="spacer"></div>
<div id="band"></div>
</div>
<div id="viewer">
<div id="vload"></div>
<img id="vimg" alt="">
@ -89,143 +103,174 @@
"use strict";
const grid = document.getElementById("grid");
const spacer = document.getElementById("spacer");
const band = document.getElementById("band");
const stext = document.getElementById("stext");
const viewer = document.getElementById("viewer");
const vimg = document.getElementById("vimg");
const vload = document.getElementById("vload");
let total = 0, loaded = 0, lastSig = "", scanning = false;
const imgById = new Map(); // id -> <img> (for thumb-ready re-requests)
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
let ids = []; // display order (mtime desc)
let total = 0, scanning = false, lastSig = "";
let cellW = 0, rowH = 0, rows = 0;
const loadedSet = new Set(); // ids whose real thumb has rendered
const imgById = new Map(); // id -> <img> currently bound in the band
const pool = []; // recycled .cell elements (band children)
function statusLine() {
stext.textContent = total + " photos" +
(scanning ? " • scanning…" : "") +
(loaded < total ? " (" + loaded + ")" : "");
(loadedSet.size < total ? " (" + loadedSet.size + ")" : "");
}
let lastIds = [];
function rebuild(ids, isScanning, found) {
const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
lastSig = sig;
scanning = isScanning;
total = ids.length; loaded = 0;
// Live rescan (a photo just landed) inserts at the top and would
// shift everything — pin the viewport to the cell that was on top.
let keepId = null;
if (lastIds.length > 0 && rowH > 0) {
const topIdx = Math.min(lastIds.length - 1, Math.floor(window.scrollY / rowH) * 3);
keepId = lastIds[topIdx];
/* ---------------- layout metrics (uniform 3-col squares) ------------- */
function layoutMetrics() {
const w = grid.clientWidth || innerWidth;
cellW = Math.floor((w - PAD * 2 - GAP * (COLS - 1)) / COLS);
if (cellW < 10) cellW = 10;
rowH = cellW + GAP;
rows = Math.ceil(total / COLS);
for (const c of pool) c.style.height = cellW + "px";
spacer.style.height = (rows * rowH - (rows ? GAP : 0) + PAD * 2) + "px";
}
grid.replaceChildren();
imgById.clear();
const frag = document.createDocumentFragment();
for (const id of ids) {
/* ---------------- band recycling -------------------------------------- */
function cellAt(i) { // create pool cells on demand
while (pool.length <= i) {
const c = document.createElement("div");
c.className = "cell";
const im = new Image();
im.loading = "lazy";
im.decoding = "async";
im.dataset.url = "http://gallery.localhost/thumb/" + id;
im.__ph = false, im.__retry = 0, im.__lazy = true;
im.src = im.dataset.url;
imgById.set(id, im);
im.__retry = 0;
im.addEventListener("load", () => {
// naturalWidth 1 => the Rust placeholder, not a real thumb
if (im.naturalWidth > 2) { loaded++; statusLine(); }
else if (im.__retry < 4) {
// im.__id = the id this img was last requested for (a stale
// load event from a previous assignment must not be credited
// to the recycled cell's new id).
const id = im.__id;
if (im.naturalWidth > 2) {
if (id !== undefined && !loadedSet.has(id)) { loadedSet.add(id); statusLine(); }
} else if (im.__retry < 4) {
im.__retry++;
jlog("js ph-load id=" + id + " retry=" + im.__retry);
// safety net in case thumb-ready was lost (page rebuild race)
setTimeout(() => {
if (!im.complete || im.naturalWidth <= 2) {
// Skip cells far outside the viewport — a fast scroll pass
// leaves hundreds of cold cells behind; re-requesting them
// all would flood the queue with junk the user cannot see.
// Cold cell: Rust served the 1x1 placeholder and queued the
// decode. Only bother re-requesting if the cell is still in
// (or near) the band — far cells will be re-requested when they
// cycle in, or via the thumb-ready event.
const r = im.getBoundingClientRect();
if (r.bottom < -innerHeight || r.top > 2 * innerHeight) return;
if (r.bottom < -2 * innerHeight || r.top > 3 * innerHeight) return;
jlog("js retry id=" + id + " v=" + im.__retry);
im.src = im.dataset.url + "?v=" + im.__retry;
}
}, 1500);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
}
});
im.addEventListener("click", () => openViewer(id));
im.addEventListener("click", () => {
const id = +c.dataset.id;
if (id) openViewer(id);
});
c.appendChild(im);
frag.appendChild(c);
band.appendChild(c);
pool.push(c);
}
grid.appendChild(frag);
lastIds = ids.slice();
return pool[i];
}
let bandDirty = true;
function markBand() { bandDirty = true; }
function updateBand() {
if (total === 0 || rowH === 0) return;
const y = window.scrollY;
const r0 = Math.max(0, Math.floor(y / rowH) - BUFFER_ROWS);
const r1 = Math.min(rows, Math.ceil((y + innerHeight) / rowH) + BUFFER_ROWS);
const i0 = r0 * COLS, i1 = r1 * COLS;
band.style.transform = "translateY(" + (r0 * rowH) + "px)";
const need = i1 - i0;
while (pool.length < need) cellAt(pool.length);
for (let s = 0; s < pool.length; s++) {
const c = pool[s];
if (s >= need) { c.style.display = "none"; continue; }
const idx = i0 + s;
const id = ids[idx];
if (id === undefined) { c.style.display = "none"; continue; }
c.style.display = "";
if (+c.dataset.idx !== idx) {
c.dataset.idx = idx;
c.dataset.id = id;
const im = c.firstElementChild;
im.__retry = 0;
im.__id = id;
// swap tracking: this id is now bound to this img
const old = +c.dataset.idold || 0;
if (old && imgById.get(old) === im) imgById.delete(old);
c.dataset.idold = id;
imgById.set(id, im);
im.src = "http://gallery.localhost/thumb/" + id;
}
}
}
function bandLoop() {
if (bandDirty) { bandDirty = false; updateBand(); }
requestAnimationFrame(bandLoop);
}
requestAnimationFrame(bandLoop);
addEventListener("scroll", () => {
markBand();
sendAnchor(false);
}, { passive: true });
addEventListener("resize", () => { layoutMetrics(); markBand(); });
/* -------- rebuild (initial list + live rescan) ------------------------ */
function rebuild(newIds, isScanning, found) {
const sig = newIds.length + ":" + (newIds[0] ?? 0) + ":" + (newIds[newIds.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
// pin the viewport: the cell currently on top keeps its screen slot
const keepId = (ids.length > 0 && rowH > 0)
? ids[Math.min(ids.length - 1, Math.floor(window.scrollY / rowH) * COLS)]
: null;
ids = newIds;
lastSig = sig;
scanning = isScanning;
total = ids.length;
layoutMetrics();
bandDirty = true;
statusLine();
eagerBand();
if (keepId !== null) {
if (keepId !== null && !scanning) {
const i = ids.indexOf(keepId);
if (i >= 0) {
requestAnimationFrame(() => scrollTo(0, Math.floor(i / 3) * rowH));
requestAnimationFrame(() => scrollTo(0, PAD + Math.floor(i / COLS) * rowH));
}
}
}
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
Tell Rust where the viewport is (throttled to ~7 Hz); the Rust worker
pre-decodes the rows ahead of us so fast scrolls hit a warm cache. */
let rowH = 0, lastAnchor = 0, anchorSent = false, anchorTrail = null;
`a` = index of the first visible grid cell. Rust pre-decodes rows
ahead of the viewport so fast scrolls hit a warm mem cache. */
let lastAnchor = 0, anchorTrail = null;
function sendAnchor(force) {
const now = performance.now();
if (!force && now - lastAnchor < 150) {
// Throttled tick: schedule a TRAILING send so the final scroll
// position is never left unreported (a stale anchor makes Rust
// drop fresh urgent work as "off-screen").
if (!anchorTrail) {
anchorTrail = setTimeout(() => {
anchorTrail = null;
sendAnchor(true);
}, 160);
anchorTrail = setTimeout(() => { anchorTrail = null; sendAnchor(true); }, 160);
}
return;
}
lastAnchor = now;
if (rowH === 0) {
const c = grid.querySelector(".cell");
if (c) rowH = c.getBoundingClientRect().height + 4; // + grid gap
}
if (rowH > 0) {
const a = Math.floor(window.scrollY / rowH) * 3;
anchorSent = true;
if (rowH > 0 && total > 0) {
const a = Math.min(total - 1, Math.floor(window.scrollY / rowH) * COLS);
try {
window.__TAURI__.event.emit("gtauri-scroll", { a: a });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + a + " y=" + Math.round(window.scrollY));
window.__TAURI__.event.emit("gtauri-scroll", { a: Math.max(0, a) });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + Math.max(0, a) + " y=" + Math.round(window.scrollY));
} catch (e) {}
eagerBand();
}
}
/* Force the visible band + next screen to load NOW (bypass Chromium's
lazy-loader intersection delay after a fast jump). Far cells stay lazy. */
function eagerBand() {
if (rowH === 0) return;
const i0 = Math.max(0, Math.floor((window.scrollY - innerHeight) / rowH) * 3 - 3);
const i1 = Math.min(total, Math.ceil((window.scrollY + 2.2 * innerHeight) / rowH) * 3 + 3);
const kids = grid.children;
let forced = 0;
for (let i = i0; i < i1; i++) {
const im = kids[i] && kids[i].firstElementChild;
if (im && !im.complete) {
im.loading = "eager";
if (im.__lazy) { im.__lazy = false; forced++; }
}
}
if (forced > 0) jlog("js eager forced=" + forced + " range=[" + i0 + "," + i1 + "]");
}
function jlog(s) {
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
}
addEventListener("scroll", () => sendAnchor(false), { passive: true });
try {
window.__TAURI__.event.listen("gallery-files", (e) => {
let p = e.payload || {};
// On Android the payload can arrive as a JSON *string* — decode it.
if (typeof p === "string") {
try { p = JSON.parse(p); } catch (err) { jlog("files parse err: " + err); }
}
@ -235,10 +280,13 @@ try {
});
// first anchor (top of grid) — Rust also warms 0..36 on its side.
setTimeout(() => sendAnchor(true), 300);
// Cold-miss round-trip: Rust served a 1x1 placeholder and queued the real
// decode off the main thread; when it lands, re-request (now a mem hit).
// Event payloads arrive as JSON strings on Android (object on some
// WebView builds) — normalize both.
// READY HANDSHAKE: the initial gallery-files emit can fire before this
// page's JS has loaded (small/fast DBs) and be lost (Tauri events are
// fire-and-forget). Tell Rust we're listening; it re-emits the state.
try { window.__TAURI__.event.emit("gtauri-js-ready"); } catch (e) {}
// Rust served a 1x1 placeholder and finished the decode off-main:
// re-request (now a mem hit). Payloads arrive as JSON strings on some
// WebView builds — normalize both.
const pid = (e) => {
const p = e.payload;
return (typeof p === "string" ? JSON.parse(p) : p) || {};
@ -246,13 +294,11 @@ try {
window.__TAURI__.event.listen("thumb-ready", (e) => {
const id = pid(e).id;
const im = imgById.get(id);
if (!im) return;
if (im.naturalWidth <= 2) {
if (im && im.naturalWidth <= 2) {
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
im.src = im.dataset.url + "?v=" + (++im.__retry);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + (++im.__retry);
}
});
// Full-res read finished (worker thread) — re-request; now a mem hit.
window.__TAURI__.event.listen("full-ready", (e) => {
const id = pid(e).id;
if (viewerId === id && !vFullDone) {
@ -260,14 +306,11 @@ try {
tryShow(id, "http://gallery.localhost/full/" + id + "?v=f" + (++vFullSeq), true);
}
});
// Android back: registering ANY "back-button" listener makes Tauri route
// the press here instead of finishing the activity (see AppPlugin.kt).
// Android back: registering ANY "back-button" listener makes Tauri
// route the press here instead of finishing the activity.
window.__TAURI__.app.onBackButtonPress(() => {
if (viewer.classList.contains("open")) {
closeViewer();
} else {
window.__TAURI__.app.exit(0);
}
if (viewer.classList.contains("open")) closeViewer();
else window.__TAURI__.app.exit(0);
});
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
@ -275,7 +318,7 @@ try {
let frLast = performance.now(), frLines = 0, frBatch = [];
function frFlush() {
if (frBatch.length === 0) return;
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loaded + "/" + total +
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loadedSet.size + "/" + total +
" scrollY=" + Math.round(window.scrollY));
const payload = frBatch.join("\n") + "\n";
frBatch = [];
@ -301,10 +344,8 @@ function fitScale() {
}
function vApply() {
// Clamp: never zoom out past fit, and pan is limited to the image
// edges — the image can never be dragged off-screen leaving blank
// space around it. On a letterboxed axis (image smaller than the
// viewport in that dimension) panning is locked to centered.
// Clamp: never zoom out past fit; pan limited to image edges — no
// blank space around the image, letterboxed axes stay centered.
const s = fitScale();
if (vScale < s) vScale = s;
const mx = Math.max(0, (base.w * vScale - innerWidth) / 2);
@ -327,13 +368,13 @@ function fitViewer(w, h) {
vApply();
}
/// Fetch url and, if it is REAL image bytes (not the 1x1 placeholder),
/// promote it into the viewer.
/// Fetch url; promote to the viewer only if REAL image bytes (not the
/// 1x1 placeholder).
function tryShow(id, url, isFull) {
const im = new Image();
im.addEventListener("load", () => {
if (viewerId !== id) return;
if (im.naturalWidth <= 2) return; // placeholder — wait for the ready event
if (im.naturalWidth <= 2) return;
vimg.src = im.src;
fitViewer(im.naturalWidth, im.naturalHeight);
if (isFull) {
@ -350,11 +391,10 @@ function openViewer(id) {
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (served from the Rust mem cache, ~1 ms).
// Same aspect ratio as the full image — the fit stays valid.
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
// 2) Full-res: Rust reads the file OFF the main thread; the first
// request gets the placeholder, `full-ready` triggers the swap.
// 2) Full-res: Rust reads the file OFF the main thread; first request
// gets the placeholder, `full-ready` triggers the swap.
tryShow(id, "http://gallery.localhost/full/" + id, true);
// 3) Safety net in case the ready event is ever lost.
if (vFullTimer) clearInterval(vFullTimer);
@ -365,7 +405,6 @@ function openViewer(id) {
}
if (++vFullSeq > 10) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
// Corrupt/unreadable file: stop saying "loading…" forever.
if (viewerId === id && vimg.naturalWidth <= 2) {
vload.textContent = "image unavailable";
}
@ -386,8 +425,8 @@ function closeViewer() {
document.getElementById("vclose").addEventListener("click", closeViewer);
addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); });
/* pointer-based pan + pinch (touch-action:none on #viewer keeps the browser
from stealing the gesture) */
/* pointer-based pan + pinch (touch-action:none keeps the browser from
stealing the gesture) */
const pts = new Map();
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
viewer.addEventListener("pointerdown", (e) => {
@ -415,8 +454,7 @@ viewer.addEventListener("pointermove", (e) => {
// The image follows the hand (midpoint drift)...
vTx += m.x - pinchMid.x;
vTy += m.y - pinchMid.y;
// ...and zooms ABOUT the midpoint: the image point under the
// midpoint stays put while the scale changes.
// ...and zooms ABOUT the midpoint.
if (pinchDist > 0 && d > 0) {
const ratio = d / pinchDist;
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
@ -426,7 +464,7 @@ viewer.addEventListener("pointermove", (e) => {
}
pinchMid = m;
pinchDist = d;
vApply(); // also re-clamps (fit min, image-edge pan limits)
vApply();
}
});
const endPt = (e) => { pts.delete(e.pointerId); };

View File

@ -4,6 +4,9 @@
<!-- all-files access: the app scans /sdcard/Pictures + /sdcard/DCIM directly
(same grant the egui gallery already holds for this package). -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@ -8,15 +8,96 @@ import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import androidx.activity.enableEdgeToEdge
import java.util.concurrent.Executors
class MainActivity : TauriActivity() {
private var observer: ContentObserver? = null
// MediaStore fast path: when the OS reports a media change (or right
// after startup), query MediaStore on a background thread for images
// indexed in the last ~2 min and push them to Rust via JNI. This is
// what makes a just-taken camera photo appear in ~1 s instead of
// waiting for the ~10 s FUSE re-read of a huge directory.
private val fsExecutor = Executors.newSingleThreadExecutor()
private val mainHandler = Handler(Looper.getMainLooper())
private var fsScheduled = false
// Rust side (this app's JNI library, loaded by TauriActivity): wakes
// the file-watcher thread. One JNI call + one channel send — nothing
// heavy ever runs on the main thread.
external fun nativeFsChange()
// Rust side reads the payload file (getExternalFilesDir/.ms_recent)
// when nativeFsChange wakes it. No string crosses JNI.
/** Query MediaStore for recently-indexed images and push to Rust. */
private fun pushRecent() {
try {
val since = System.currentTimeMillis() / 1000 - 120
val sb = StringBuilder()
val rows = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.RELATIVE_PATH,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATE_ADDED
),
"${MediaStore.Images.Media.DATE_ADDED} >= ?",
arrayOf(since.toString()),
"${MediaStore.Images.Media.DATE_ADDED} DESC"
)
if (rows == null) {
android.util.Log.w("gallery-ms", "MediaStore query returned null (permission?)")
return
}
rows.use { c ->
val iData = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val iRel = c.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
val iSize = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val iAdded = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
while (c.moveToNext()) {
var data = c.getString(iData)
if (data.isNullOrEmpty() && iRel >= 0) {
val rel = c.getString(iRel) ?: continue
data = "/storage/emulated/0/" + rel
}
if (data.isNullOrEmpty()) continue
sb.append(data).append('|')
.append(c.getLong(iAdded)).append('|')
.append(c.getLong(iSize)).append('\n')
}
}
android.util.Log.i("gallery-ms", "pushRecent rows=" + sb.length)
if (sb.isNotEmpty()) {
// Hand the payload to Rust via a file (robust under the native
// bridge; the JNI call stays the proven zero-arg wake).
val dir = getExternalFilesDir(null) ?: return
val tmp = java.io.File(dir, ".ms_recent.tmp")
val fin = java.io.File(dir, ".ms_recent")
tmp.writeText(sb.toString())
if (!tmp.renameTo(fin)) {
tmp.copyTo(fin, overwrite = true)
tmp.delete()
}
nativeFsChange()
}
} catch (e: Exception) {
android.util.Log.w("gallery-ms", "pushRecent failed: " + e)
}
}
/** Debounced (800 ms, main thread) schedule of pushRecent on the
* background executor — the observer can fire in bursts. */
private fun scheduleFsPush() {
if (fsScheduled) return
fsScheduled = true
mainHandler.postDelayed({
fsScheduled = false
fsExecutor.execute { pushRecent() }
}, 800)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@ -27,6 +108,7 @@ class MainActivity : TauriActivity() {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
nativeFsChange()
scheduleFsPush()
}
}
observer = obs
@ -35,10 +117,15 @@ class MainActivity : TauriActivity() {
contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, obs
)
// Startup fast path: if the app was killed after a photo was taken,
// the observer won't fire — push once after the media scanner has
// had a moment to index anything recent.
mainHandler.postDelayed({ scheduleFsPush() }, 1500)
}
override fun onDestroy() {
observer?.let { contentResolver.unregisterContentObserver(it) }
fsExecutor.shutdownNow()
super.onDestroy()
}
}

View File

@ -193,6 +193,48 @@ pub fn apply(
Ok((inserted, updated, deleted))
}
/// Upsert entries: insert new, update changed — NEVER deletes (the full
/// FS rescan owns deletions). Used by the MediaStore fast path, which
/// only ever ADDS recently-indexed files.
pub fn upsert(
conn: &mut Connection,
entries: &[Entry],
) -> Result<(usize, usize), rusqlite::Error> {
let mut inserted = 0;
let mut updated = 0;
let tx = conn.transaction()?;
{
let mut ins = tx.prepare(
"INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)",
)?;
let mut sel = tx.prepare(
"SELECT mtime, size FROM items WHERE path = ?1",
)?;
let mut upd = tx.prepare(
"UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \
orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1",
)?;
for e in entries {
let old: Option<(i64, i64)> = sel
.query_row(params![e.path], |r| Ok((r.get(0)?, r.get(1)?)))
.ok();
match old {
Some((m, s)) if m == e.mtime && s == e.size => {}
Some(_) => {
upd.execute(params![e.path, e.mtime, e.size])?;
updated += 1;
}
None => {
ins.execute(params![e.path, e.mtime, e.size])?;
inserted += 1;
}
}
}
}
tx.commit()?;
Ok((inserted, updated))
}
/// Number of rows in the DB.
pub fn count(conn: &mut Connection) -> Result<i64, rusqlite::Error> {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get(0))?;

View File

@ -920,6 +920,84 @@ fn rescan_subtrees(app: &tauri::AppHandle, changed: &[PathBuf]) {
tr(&format!("watch: incr changed={} fresh={added}", changed.len()));
}
// ---------------------------------------------------------------------
// MediaStore fast path (Android): when the OS says the media changed,
// query MediaStore for images indexed in the last ~2 min. One indexed
// provider query (~50 ms) vs a ~10 s FUSE re-read of a huge directory —
// this is what makes a just-taken camera photo appear in ~1 s.
// ---------------------------------------------------------------------
/// MediaStore fast path (Android): read the .ms_recent payload dropped
/// by MainActivity and apply it. The query itself runs on Kotlin's
/// background executor (no Rust→Java FFI, robust under the native
/// bridge); only a zero-arg wake crosses JNI.
#[cfg(target_os = "android")]
fn apply_mediasotre_payload(app: &tauri::AppHandle) {
let path = ms_recent_path();
let Ok(text) = std::fs::read_to_string(&path) else { return };
let _ = std::fs::remove_file(&path);
if text.is_empty() {
return;
}
tr(&format!("mediastore push: {} bytes, {} lines", text.len(), text.lines().count()));
mediastore_apply(app, &text);
}
/// Path of the .ms_recent handoff file (external files dir — MainActivity
/// writes it with getExternalFilesDir(null)).
#[cfg(target_os = "android")]
fn ms_recent_path() -> std::path::PathBuf {
external_files_dir().join(".ms_recent")
}
#[cfg(not(target_os = "android"))]
fn apply_mediasotre_payload(_app: &tauri::AppHandle) {}
/// MediaStore fast path (Android): apply a "path|addedSec|size" payload
/// pushed from MainActivity (which does the MediaStore query on a
/// background thread — no Rust→Java FFI). Upserts the rows, re-emits.
/// Never deletes (the FS rescan owns deletions). This is what makes a
/// just-taken camera photo appear in ~1 s instead of waiting for the
/// ~10 s FUSE re-read of a huge directory.
#[cfg(target_os = "android")]
fn mediastore_apply(app: &tauri::AppHandle, text: &str) {
let mut entries = Vec::new();
for line in text.lines() {
let Some(path) = line.split('|').next() else { continue };
if path.is_empty() || !scan::is_image_path(path) {
continue;
}
if let Ok(md) = std::fs::metadata(path) {
entries.push(scan::Entry {
path: path.to_string(),
mtime: mtime_ns(&md),
size: md.len() as i64,
});
}
}
if entries.is_empty() {
return;
}
let mut conn = match db::open(&db::db_path()) {
Ok(c) => c,
Err(e) => {
log::warn!("mediastore db: {e}");
return;
}
};
match db::upsert(&mut conn, &entries) {
Ok((ins, upd)) => {
if ins + upd > 0 {
tr(&format!("mediastore fast +{ins} new, {upd} changed"));
if let Err(e) = load_meta(&mut conn) {
log::warn!("mediastore meta: {e}");
}
emit_files(app, false, META.read().unwrap().len());
}
}
Err(e) => log::warn!("mediastore upsert: {e}"),
}
}
/// Watcher wake events.
#[derive(Debug, Clone, Copy)]
enum WEvent {
@ -933,9 +1011,10 @@ enum WEvent {
static WATCH_TX: LazyLock<Mutex<Option<mpsc::Sender<WEvent>>>> =
LazyLock::new(|| Mutex::new(None));
/// Android: called by MainActivity's ContentObserver via JNI.
/// Signature: (JNIEnv*, jobject) for an instance method — both
/// ignored; the symbol name is what binds.
/// Android: called by MainActivity's ContentObserver via JNI (and after
/// each MediaStore fast-path payload drop). Signature: (JNIEnv*,
/// jobject) for an instance method. One channel send — nothing heavy,
/// never blocks the main thread.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_wow_gmp_gallery_MainActivity_nativeFsChange(
@ -1035,6 +1114,14 @@ fn watch_files(app: tauri::AppHandle) {
std::thread::sleep(std::time::Duration::from_millis(600));
while rx.try_recv().is_ok() {}
}
// MediaStore fast path: MainActivity (which does the MediaStore
// query on a background thread) drops a "path|added|size" payload
// in .ms_recent and wakes us via nativeFsChange. Apply it now —
// a just-taken camera photo lands in ~1 s, without waiting for
// the ~10 s FUSE re-read of a huge directory.
if matches!(woke, Some(WEvent::Fs) | Some(WEvent::Full)) {
apply_mediasotre_payload(&app);
}
let next = dir_sig(&scan::roots());
let changed = if baseline {
baseline = false;
@ -1113,6 +1200,19 @@ pub fn run() {
tr(&s);
});
// JS ready handshake: the WebView's page loads ~2-3 s after
// process start, while the first gallery-files emit can land
// 10 ms in (small/fast DB) and be lost. On handshake, re-emit
// the current state so the grid always gets its initial list.
let _ = app.listen("gtauri-js-ready", move |event| {
let _ = event;
let h = APP_HANDLE.lock().unwrap().clone();
if let Some(h) = h {
tr("js ready — re-emitting state");
emit_files(&h, false, META.read().unwrap().len());
}
});
// Scroll anchor from JS (throttled to ~7 Hz): `a` = index of the
// first visible grid cell. REPLACES the prefetch window with
// [a, a+60) — the visible band plus ~13 rows ahead. Stale
@ -1162,6 +1262,9 @@ pub fn run() {
// Warm the first two screens before Chromium's lazy loader gets
// to them (it requests ~30 at once; we'd rather be ahead).
set_prefetch_window(0, 36);
// (The startup MediaStore fast path is triggered by
// MainActivity.onCreate — it pushes via
// nativeMediaStoreResults ~1.5 s in.)
spawn_scan(handle.clone());
// New photos appear in the grid ~2 s after being written.
std::thread::spawn(move || watch_files(handle));

View File

@ -20,6 +20,19 @@ pub const IMAGE_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff",
];
/// True if the path looks like an image we index (hidden files and
/// non-image extensions excluded).
pub fn is_image_path(p: &str) -> bool {
let pb = std::path::Path::new(p);
let name = pb.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') {
return false;
}
pb.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()))
}
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
pub fn roots() -> Vec<PathBuf> {
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
@ -163,6 +176,17 @@ fn walk_dir(
Some(e) => e,
None => return,
};
// GROUND-TRUTH INSTRUMENTATION: raw getdents count for this directory,
// before any filtering. If raw < reality, the VFS served a short
// listing to this process; if raw == reality but the walk total is
// low, a filter below dropped entries (our bug).
if dir.parent().is_some() {
crate::tr(&format!(
"rawdir {:?}: {} entries",
dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
entries.len()
));
}
// STALE-LISTING CROSS-CHECK (see walk_stream docs): if the DB holds
// many more files for this directory than the enumeration produced,
// re-read the directory a few times and keep the best listing.
@ -197,9 +221,13 @@ fn walk_dir(
}
}
}
let mut n_ftfail = 0u32;
let mut n_statfail = 0u32;
let mut n_pushed = 0u32;
let raw_n = entries.len();
for ent in entries {
let p = ent.path();
let Ok(ft) = ent.file_type() else { continue };
let Ok(ft) = ent.file_type() else { n_ftfail += 1; continue };
if ft.is_dir() {
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
@ -225,7 +253,10 @@ fn walk_dir(
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
continue;
}
let Ok(md) = std::fs::metadata(&p) else { continue };
let Ok(md) = std::fs::metadata(&p) else {
n_statfail += 1;
continue;
};
let mtime = md
.modified()
.ok()
@ -237,10 +268,21 @@ fn walk_dir(
mtime,
size: md.len() as i64,
});
n_pushed += 1;
if out.len() % 512 == 0 {
on_batch(out);
}
}
if n_ftfail > 0 || n_statfail > 0 {
crate::tr(&format!(
"walkdir {:?}: raw {} pushed {} ftfail {} statfail {}",
dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
raw_n,
n_pushed,
n_ftfail,
n_statfail
));
}
}
#[cfg(test)]