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.
This commit is contained in:
Greg Pomerantz 2026-11-02 08:01:13 -05:00
parent 9e52dc0355
commit 1bc1d6beb3
4 changed files with 119 additions and 6 deletions

View File

@ -113,6 +113,7 @@ const vload = document.getElementById("vload");
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12; const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
let ids = []; // display order (mtime desc) 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 total = 0, scanning = false, lastSig = "";
let cellW = 0, rowH = 0, rows = 0; let cellW = 0, rowH = 0, rows = 0;
const loadedSet = new Set(); // ids whose real thumb has rendered 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.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", () => { im.addEventListener("click", () => {
const id = +c.dataset.id; const id = +c.dataset.id;
if (id) openViewer(id); if (id) openViewer(id);
@ -193,7 +212,9 @@ function updateBand() {
const id = ids[idx]; const id = ids[idx];
if (id === undefined) { c.style.display = "none"; continue; } if (id === undefined) { c.style.display = "none"; continue; }
c.style.display = ""; 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.idx = idx;
c.dataset.id = id; c.dataset.id = id;
const im = c.firstElementChild; const im = c.firstElementChild;
@ -232,6 +253,8 @@ function rebuild(newIds, isScanning, found) {
lastSig = sig; lastSig = sig;
scanning = isScanning; scanning = isScanning;
total = ids.length; total = ids.length;
idSet.clear();
for (const id of ids) idSet.add(id);
layoutMetrics(); layoutMetrics();
bandDirty = true; bandDirty = true;
statusLine(); statusLine();

View File

@ -1,18 +1,56 @@
package wow.gmp.gallery package wow.gmp.gallery
import android.Manifest
import android.database.ContentObserver import android.database.ContentObserver
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.content.pm.PackageManager
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private var observer: ContentObserver? = null 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 // MediaStore fast path: when the OS reports a media change (or right
// after startup), query MediaStore on a background thread for images // 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 // 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 // the observer won't fire — push once after the media scanner has
// had a moment to index anything recent. // had a moment to index anything recent.
mainHandler.postDelayed({ scheduleFsPush() }, 1500) mainHandler.postDelayed({ scheduleFsPush() }, 1500)
requestMediaPermission()
} }
override fun onDestroy() { override fun onDestroy() {

View File

@ -147,8 +147,14 @@ pub fn apply(
to_delete += 1; 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 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; let mut do_delete = true;
if suspect { if suspect {
let last_walked: i64 = tx let last_walked: i64 = tx
@ -180,6 +186,30 @@ pub fn apply(
params![entries.len().to_string()], 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 { if do_delete {
for p in existing.keys() { for p in existing.keys() {
if !seen.contains(p) { if !seen.contains(p) {

View File

@ -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 /// Per-directory file counts from the DB — the reference the walk uses
/// to detect stale (silently truncated) VFS directory listings. /// to detect stale (silently truncated) VFS directory listings.
fn expected_dir_counts() -> std::collections::HashMap<std::path::PathBuf, i64> { fn expected_dir_counts() -> std::collections::HashMap<std::path::PathBuf, i64> {
@ -760,15 +776,20 @@ fn walk_once(app: &tauri::AppHandle) -> Vec<scan::Entry> {
/// single apply unless a previous walk already saw the low count. /// single apply unless a previous walk already saw the low count.
fn rescan(app: &tauri::AppHandle) { fn rescan(app: &tauri::AppHandle) {
let mut entries = walk_once(app); 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 db_count = expected_count();
let ref_count = db_count.max(walked_max());
let walked = entries.len() as i64; let walked = entries.len() as i64;
if walked < db_count.saturating_sub(100) if ref_count > 0
&& (db_count - walked) as f64 > (db_count as f64) * 0.2 && walked < ref_count.saturating_sub(100)
&& (ref_count - walked) as f64 > (ref_count as f64) * 0.15
{ {
tr(&format!( tr(&format!(
"rescan: walk {} << db {} — re-walk in 2s", "rescan: walk {} << ref {} (db {}) — re-walk in 2s",
entries.len(), entries.len(),
ref_count,
db_count db_count
)); ));
std::thread::sleep(std::time::Duration::from_secs(2)); std::thread::sleep(std::time::Duration::from_secs(2));