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;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user