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:
parent
9e52dc0355
commit
1bc1d6beb3
25
harnesses/gtauri/frontend/dist/index.html
vendored
25
harnesses/gtauri/frontend/dist/index.html
vendored
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user