- images.rs: libjpeg-turbo 1/2..1/8 shrink decode (48MP @1/8 ~2.3MB peak),
all 8 EXIF orientations, imageops::resize final fit, PNG via image crate,
q85 re-encode for the disk cache
- thumb.rs: dispatcher + N=cores/2 workers (2..8), cache
thumbs/{sha1(path|mtime|size|target)}.jpg (target in key: list 64px and
grid 500px coexist), atomic rename, generation-based cancellation,
per-worker sqlite set_thumb, 256-job cadence logs
- lib.rs: AppState OnceLock, 10Hz drain timer (leaked !Send Timer),
scroll prefetch (50-row margin), do_prefetch gating for scan batches
- main.slint/db.rs: rows carry images; list_entries + set_thumb
- build.sh: NDK CMake toolchain env for turbojpeg-sys (ANDROID_JAR vs
ANDROID_PLATFORM conflict), nasm on PATH, ADB_SERIAL, debug-aarch64
- HANDOFF: M2 done + acceptance data; HEIC deferred (0 HEIC on device);
transient first-launch black screen parked as §10.7 (unreproducible)
Verified: 12 unit tests + 5k engine test; Pixel 9 Pro 8577 photos —
cold scan ~5s streaming, warm relaunch instant, thumbnails in every row,
~100% cache hits on relaunch; release aarch64 APK 9.8 MB.
246 lines
8.6 KiB
Rust
246 lines
8.6 KiB
Rust
//! SQLite metadata store (M1). Schema follows HANDOFF.md §4:
|
|
//! one row per image; M1 fills `path/mtime/size`, the thumbnail engine (M2)
|
|
//! fills `width/height/orientation/thumb_path`, `date_taken` later.
|
|
|
|
use rusqlite::{params, Connection};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
|
|
use crate::scan::Entry;
|
|
|
|
#[cfg(target_os = "android")]
|
|
const ANDROID_PACKAGE: &str = "wow.gmp.gallery";
|
|
|
|
/// App-writable data dir on Android. Prefers the per-app external dir
|
|
/// (/storage/emulated/0/Android/data/<pkg>/files) so the DB is easy to
|
|
/// inspect via `adb`; falls back to the internal files dir, which is always
|
|
/// writable. Neither `EXTERNAL_DATA_DIR` (not set by this NativeActivity
|
|
/// setup) nor `temp_dir()` (=/data/user/0, not writable) can be trusted.
|
|
#[cfg(target_os = "android")]
|
|
fn android_data_dir() -> PathBuf {
|
|
if let Some(ext) = std::env::var_os("EXTERNAL_DATA_DIR").map(PathBuf::from) {
|
|
if std::fs::create_dir_all(&ext).is_ok() {
|
|
return ext;
|
|
}
|
|
}
|
|
if let Some(storage) = std::env::var_os("EXTERNAL_STORAGE").map(PathBuf::from) {
|
|
let ext = storage
|
|
.join("Android/data")
|
|
.join(ANDROID_PACKAGE)
|
|
.join("files");
|
|
if std::fs::create_dir_all(&ext).is_ok() {
|
|
return ext;
|
|
}
|
|
}
|
|
PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files"))
|
|
}
|
|
|
|
/// DB file lives in app-private storage:
|
|
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
|
|
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
|
|
pub fn db_path() -> PathBuf {
|
|
if let Ok(p) = std::env::var("GALLERY_DB_PATH") {
|
|
return PathBuf::from(p); // test hook
|
|
}
|
|
#[cfg(target_os = "android")]
|
|
let dir = android_data_dir();
|
|
#[cfg(not(target_os = "android"))]
|
|
let dir = {
|
|
let data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
|
let base = data_home.unwrap_or_else(|| {
|
|
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
|
|
home.join(".local/share")
|
|
});
|
|
base.join("gallery")
|
|
};
|
|
dir.join("gallery.db")
|
|
}
|
|
|
|
pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
|
|
let conn = Connection::open(path)?;
|
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS items (
|
|
path TEXT PRIMARY KEY,
|
|
mtime INTEGER NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
width INTEGER,
|
|
height INTEGER,
|
|
orientation INTEGER,
|
|
thumb_path TEXT,
|
|
date_taken INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS items_mtime ON items (mtime DESC);",
|
|
)?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Apply a full filesystem snapshot incrementally: insert new paths, refresh
|
|
/// rows whose mtime/size changed, delete vanished ones.
|
|
/// Returns (inserted, updated, deleted).
|
|
pub fn apply(
|
|
conn: &mut Connection,
|
|
entries: &[Entry],
|
|
) -> Result<(usize, usize, usize), rusqlite::Error> {
|
|
// Existing state: path -> (mtime, size)
|
|
let mut existing: HashMap<String, (i64, i64)> = HashMap::with_capacity(entries.len());
|
|
{
|
|
let mut st = conn.prepare("SELECT path, mtime, size FROM items")?;
|
|
let rows = st.query_map([], |r| {
|
|
Ok((
|
|
r.get::<_, String>(0)?,
|
|
r.get::<_, i64>(1)?,
|
|
r.get::<_, i64>(2)?,
|
|
))
|
|
})?;
|
|
for row in rows {
|
|
let (p, m, s) = row?;
|
|
existing.insert(p, (m, s));
|
|
}
|
|
}
|
|
|
|
let mut inserted = 0;
|
|
let mut updated = 0;
|
|
let mut deleted = 0;
|
|
let tx = conn.transaction()?;
|
|
{
|
|
let mut ins = tx.prepare(
|
|
"INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)",
|
|
)?;
|
|
// mtime/size changed → content changed → derived columns are stale.
|
|
let mut upd = tx.prepare(
|
|
"UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \
|
|
orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1",
|
|
)?;
|
|
let mut del = tx.prepare("DELETE FROM items WHERE path = ?1")?;
|
|
|
|
let mut seen = HashSet::with_capacity(entries.len());
|
|
for e in entries {
|
|
seen.insert(e.path.clone());
|
|
match existing.get(&e.path) {
|
|
Some(&(m, s)) if m == e.mtime && s == e.size => {}
|
|
Some(_) => {
|
|
upd.execute(params![e.path, e.mtime, e.size])?;
|
|
updated += 1;
|
|
}
|
|
None => {
|
|
ins.execute(params![e.path, e.mtime, e.size])?;
|
|
inserted += 1;
|
|
}
|
|
}
|
|
}
|
|
for p in existing.keys() {
|
|
if !seen.contains(p) {
|
|
del.execute(params![p])?;
|
|
deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
tx.commit()?;
|
|
Ok((inserted, updated, deleted))
|
|
}
|
|
|
|
/// All known entries, newest first.
|
|
pub fn list_entries(conn: &mut Connection) -> Result<Vec<Entry>, rusqlite::Error> {
|
|
let mut st = conn.prepare("SELECT path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
|
|
let rows = st.query_map([], |r| {
|
|
Ok(Entry { path: r.get(0)?, mtime: r.get(1)?, size: r.get(2)? })
|
|
})?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Update the derived thumbnail columns once the pool has finished a job.
|
|
pub fn set_thumb(
|
|
conn: &mut Connection,
|
|
path: &str,
|
|
width: i64,
|
|
height: i64,
|
|
orientation: i64,
|
|
thumb_path: &str,
|
|
) -> Result<(), rusqlite::Error> {
|
|
conn.execute(
|
|
"UPDATE items SET width = ?2, height = ?3, orientation = ?4, thumb_path = ?5 \
|
|
WHERE path = ?1",
|
|
params![path, width, height, orientation, thumb_path],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::scan::{walk, Entry};
|
|
|
|
fn entry(path: &str, mtime: i64, size: i64) -> Entry {
|
|
Entry { path: path.into(), mtime, size }
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_apply() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut conn = open(&dir.path().join("t.db")).unwrap();
|
|
|
|
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
|
|
assert_eq!((i, u, d), (2, 0, 0));
|
|
|
|
// No changes → no-op.
|
|
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
|
|
assert_eq!((i, u, d), (0, 0, 0));
|
|
|
|
// mtime changed → update; 3.jpg new; 2.jpg removed.
|
|
let (i, u, d) = apply(
|
|
&mut conn,
|
|
&[entry("/a/1.jpg", 5, 11), entry("/a/3.jpg", 3, 30)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!((i, u, d), (1, 1, 1));
|
|
|
|
let list = list_entries(&mut conn).unwrap();
|
|
let paths: Vec<&str> = list.iter().map(|e| e.path.as_str()).collect();
|
|
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
|
|
}
|
|
|
|
#[test]
|
|
fn set_thumb_updates_row_and_invalidation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut conn = open(&dir.path().join("t.db")).unwrap();
|
|
apply(&mut conn, &[entry("/a/1.jpg", 1, 10)]).unwrap();
|
|
set_thumb(&mut conn, "/a/1.jpg", 8000, 6016, 6, "/cache/abc.jpg").unwrap();
|
|
let thumb: String = conn
|
|
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(thumb, "/cache/abc.jpg");
|
|
|
|
// mtime change → apply() invalidates the derived columns.
|
|
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 2, 12)]).unwrap();
|
|
assert_eq!((i, u, d), (0, 1, 0));
|
|
let thumb: Option<String> = conn
|
|
.query_row("SELECT thumb_path FROM items WHERE path = '/a/1.jpg'", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(thumb, None);
|
|
}
|
|
|
|
#[test]
|
|
fn scan_then_apply_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
|
|
std::fs::write(dir.path().join("a.jpg"), b"xx").unwrap();
|
|
std::fs::write(dir.path().join("sub").join("b.png"), b"y").unwrap();
|
|
std::fs::write(dir.path().join("notes.txt"), b"nope").unwrap();
|
|
|
|
let db_dir = tempfile::tempdir().unwrap();
|
|
let mut conn = open(&db_dir.path().join("t.db")).unwrap();
|
|
let entries = walk(&[dir.path().to_path_buf()], &|_| {});
|
|
let (i, u, d) = apply(&mut conn, &entries).unwrap();
|
|
assert_eq!((i, u, d), (2, 0, 0));
|
|
|
|
let list = list_entries(&mut conn).unwrap();
|
|
assert_eq!(list.len(), 2);
|
|
assert!(list.iter().all(|e| e.path.contains(dir.path().to_str().unwrap())));
|
|
}
|
|
}
|