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;
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();

View File

@ -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() {

View File

@ -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) {

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
/// to detect stale (silently truncated) VFS directory listings.
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.
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));