diff --git a/harnesses/gtauri/frontend/dist/index.html b/harnesses/gtauri/frontend/dist/index.html index e54f0f3..d114f1f 100644 --- a/harnesses/gtauri/frontend/dist/index.html +++ b/harnesses/gtauri/frontend/dist/index.html @@ -113,6 +113,7 @@ const vload = document.getElementById("vload"); const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12; let ids = []; // display order (mtime desc) +const idSet = new Set(); // id membership (O(1) stale-id checks on 404) let total = 0, scanning = false, lastSig = ""; let cellW = 0, rowH = 0, rows = 0; const loadedSet = new Set(); // ids whose real thumb has rendered @@ -163,6 +164,24 @@ function cellAt(i) { // create pool cells on demand im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry; } }); + im.addEventListener("error", () => { + // The protocol returned no image (404: the file was deleted and + // the list update has not reached this cell yet, or a transient + // read failure). Without this handler the cell would sit as an + // empty dark box forever. + const id = im.__id; + if (id === undefined) return; + if (!idSet.has(id)) { + // id no longer in the list — drop the content; the next band + // pass reassigns (or hides) this cell. + im.removeAttribute("src"); + markBand(); + } else if (im.__retry < 4) { + im.__retry++; + jlog("js err id=" + id + " v=" + im.__retry); + im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry; + } + }); im.addEventListener("click", () => { const id = +c.dataset.id; if (id) openViewer(id); @@ -193,7 +212,9 @@ function updateBand() { const id = ids[idx]; if (id === undefined) { c.style.display = "none"; continue; } c.style.display = ""; - if (+c.dataset.idx !== idx) { + // Compare BOTH idx and id: after a deletion the same slot index + // holds a different photo — idx alone would keep the stale image. + if (+c.dataset.idx !== idx || c.dataset.id != id) { c.dataset.idx = idx; c.dataset.id = id; const im = c.firstElementChild; @@ -232,6 +253,8 @@ function rebuild(newIds, isScanning, found) { lastSig = sig; scanning = isScanning; total = ids.length; + idSet.clear(); + for (const id of ids) idSet.add(id); layoutMetrics(); bandDirty = true; statusLine(); diff --git a/harnesses/gtauri/gen/android/app/src/main/java/wow/gmp/gallery/MainActivity.kt b/harnesses/gtauri/gen/android/app/src/main/java/wow/gmp/gallery/MainActivity.kt index 5bb1178..e7319ad 100644 --- a/harnesses/gtauri/gen/android/app/src/main/java/wow/gmp/gallery/MainActivity.kt +++ b/harnesses/gtauri/gen/android/app/src/main/java/wow/gmp/gallery/MainActivity.kt @@ -1,18 +1,56 @@ package wow.gmp.gallery +import android.Manifest import android.database.ContentObserver import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore +import android.content.pm.PackageManager import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import java.util.concurrent.Executors class MainActivity : TauriActivity() { private var observer: ContentObserver? = null + // Runtime request for the media-read permission. The MediaStore fast + // path (instant camera-photo appearance) needs it; WITHOUT it the + // query returns nothing and new photos only appear after the slow + // full FUSE walk (~10-30 s on a big library). Declaring it in the + // manifest is not enough on Android 13+. + private val permLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grantOneMoreChance() } + + private fun mediaPermission(): String = + if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES + else Manifest.permission.READ_EXTERNAL_STORAGE + + private fun requestMediaPermission() { + val p = mediaPermission() + if (checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED) return + try { + permLauncher.launch(arrayOf(p)) + } catch (e: Exception) { + android.util.Log.w("gallery-ms", "perm launch: " + e) + } + } + + // If the user denied, the app still works — photos arrive via the + // slow walk. Retry the request once per process start only (no + // nagging loop). + private var permRetried = false + private fun grantOneMoreChance() { + if (!permRetried && checkSelfPermission(mediaPermission()) != PackageManager.PERMISSION_GRANTED) { + permRetried = true + permLauncher.launch(arrayOf(mediaPermission())) + } + } + // 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 @@ -121,6 +159,7 @@ class MainActivity : TauriActivity() { // the observer won't fire — push once after the media scanner has // had a moment to index anything recent. mainHandler.postDelayed({ scheduleFsPush() }, 1500) + requestMediaPermission() } override fun onDestroy() { diff --git a/harnesses/gtauri/src/db.rs b/harnesses/gtauri/src/db.rs index f7a3a10..a897974 100644 --- a/harnesses/gtauri/src/db.rs +++ b/harnesses/gtauri/src/db.rs @@ -147,8 +147,14 @@ pub fn apply( to_delete += 1; } } + // 10%: observed FUSE truncations lose 15-17% of a big library, + // which sailed under the old 20% threshold and poisoned the DB. + // A user deleting >10% of a library in one operation is rare; + // if it happens, the second consecutive low walk (two-strike) + // applies the deletion minutes later. Data loss is the + // unforgivable failure mode — err on the side of refusing. let suspect = existing.len() > 100 - && (to_delete as f64) > (existing.len() as f64) * 0.2; + && (to_delete as f64) > (existing.len() as f64) * 0.10; let mut do_delete = true; if suspect { let last_walked: i64 = tx @@ -180,6 +186,30 @@ pub fn apply( params![entries.len().to_string()], ); } + // walked_max: the largest count ever applied. Layer-2 truncation + // detection in rescan() compares against max(db, walked_max), so + // a poisoned (shrunk) DB cannot silence the re-walk check. + let prev_max: i64 = tx + .query_row("SELECT value FROM meta WHERE key='walked_max'", [], |r| { + r.get::<_, String>(0) + }) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + // Decay when deletions were CONFIRMED (two-strike passed, or no + // suspicion): a legitimately shrunk library lowers the ceiling. + // When deletions were REFUSED, keep the high watermark so the + // poisoned-DB chicken-and-egg stays detectable. + let new_max = if do_delete && (entries.len() as i64) < prev_max { + entries.len() as i64 + } else { + prev_max.max(existing.len() as i64).max(entries.len() as i64) + }; + let _ = tx.execute( + "INSERT INTO meta (key, value) VALUES ('walked_max', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![new_max.to_string()], + ); if do_delete { for p in existing.keys() { if !seen.contains(p) { diff --git a/harnesses/gtauri/src/lib.rs b/harnesses/gtauri/src/lib.rs index 8a86446..3ab7ac9 100644 --- a/harnesses/gtauri/src/lib.rs +++ b/harnesses/gtauri/src/lib.rs @@ -721,6 +721,22 @@ fn expected_count() -> i64 { } } +/// Largest file count ever applied to the DB (0 if never recorded). +/// Layer-2 truncation detection compares against max(db, this) so a +/// poisoned (truncation-shrunk) DB cannot silence the re-walk check. +fn walked_max() -> i64 { + match db::open(&db::db_path()) { + Ok(mut c) => c + .query_row("SELECT value FROM meta WHERE key='walked_max'", [], |r| { + r.get::<_, String>(0) + }) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + Err(_) => 0, + } +} + /// Per-directory file counts from the DB — the reference the walk uses /// to detect stale (silently truncated) VFS directory listings. fn expected_dir_counts() -> std::collections::HashMap { @@ -760,15 +776,20 @@ fn walk_once(app: &tauri::AppHandle) -> Vec { /// single apply unless a previous walk already saw the low count. fn rescan(app: &tauri::AppHandle) { let mut entries = walk_once(app); - // Whole-walk re-check (layer 2). + // Whole-walk re-check (layer 2). Reference = max(db, walked_max): + // a DB that was poisoned downward by an earlier truncated walk must + // not be able to make a matching truncated walk look normal. let db_count = expected_count(); + let ref_count = db_count.max(walked_max()); let walked = entries.len() as i64; - if walked < db_count.saturating_sub(100) - && (db_count - walked) as f64 > (db_count as f64) * 0.2 + if ref_count > 0 + && walked < ref_count.saturating_sub(100) + && (ref_count - walked) as f64 > (ref_count as f64) * 0.15 { tr(&format!( - "rescan: walk {} << db {} — re-walk in 2s", + "rescan: walk {} << ref {} (db {}) — re-walk in 2s", entries.len(), + ref_count, db_count )); std::thread::sleep(std::time::Duration::from_secs(2));