M1: metadata scan + sqlite DB + placeholder list

- scan.rs: recursive walk of Pictures/DCIM (GALLERY_SCAN_DIRS override),
  image-ext detection, mtime/size capture, no symlink-dir following
- db.rs: rusqlite(bundled) store; incremental apply (insert/update/delete)
  keyed on path with mtime/size change detection; derived columns reset on
  change; Android DB path in Android/data/<pkg>/files (EXTERNAL_DATA_DIR is
  unset in this NativeActivity setup)
- lib.rs: start_scan() background thread -> invoke_from_event_loop ->
  VecModel + status line; called from desktop and Android entry points
- main.slint: ListView of paths (M1), status line with scan stats
- main.rs: fix desktop bin for 1.18.1 (no slint::Result; ComponentHandle)
- build.sh: export NDK clang CC_/CXX_/AR_ per target (bundled sqlite)
- tests: 5k-file scan < 2s, incremental apply, roundtrip

Verified on emulator: 5002 files in 153-226 ms; relaunch 0+ 0~ 0-;
touch -> 1~, delete -> 1-.
This commit is contained in:
Greg Pomerantz 2026-10-27 13:28:33 -04:00
parent 53ba74747d
commit e1982eade4
10 changed files with 537 additions and 16 deletions

74
Cargo.lock generated
View File

@ -1186,6 +1186,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@ -1484,8 +1496,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"log", "log",
"rusqlite",
"slint", "slint",
"slint-build", "slint-build",
"tempfile",
] ]
[[package]] [[package]]
@ -1727,6 +1741,15 @@ dependencies = [
"foldhash 0.2.0", "foldhash 0.2.0",
] ]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -2592,6 +2615,17 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "libudev-sys" name = "libudev-sys"
version = "0.1.4" version = "0.1.4"
@ -3847,6 +3881,30 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "rsqlite-vfs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "rusqlite"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c026b4444579e8d754556c6c75d040d69c4a44283cdb648ab8a8d5965c2cfa8"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@ -4289,6 +4347,16 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
] ]
[[package]]
name = "sqlite-wasm-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618"
dependencies = [
"cc",
"rsqlite-vfs",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -4829,6 +4897,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View File

@ -10,10 +10,14 @@ crate-type = ["cdylib", "lib"]
slint = { version = "1.18.1", features = ["backend-android-activity-06"] } slint = { version = "1.18.1", features = ["backend-android-activity-06"] }
log = "0.4" log = "0.4"
android_logger = { version = "0.14", target-family = "android" } android_logger = { version = "0.14", target-family = "android" }
rusqlite = { version = "0.41.0", features = ["bundled"] }
[build-dependencies] [build-dependencies]
slint-build = "1.18.1" slint-build = "1.18.1"
[dev-dependencies]
tempfile = "3.27.0"
# --- Android packaging (cargo-apk2 1.5) --- # --- Android packaging (cargo-apk2 1.5) ---
# NOTE: uses_permission is a TOP-LEVEL android key (ndk-build2 manifest layer), # NOTE: uses_permission is a TOP-LEVEL android key (ndk-build2 manifest layer),
# not under [package.metadata.android.application]. max_sdk_version supported. # not under [package.metadata.android.application]. max_sdk_version supported.

View File

@ -269,11 +269,30 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
- **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs, - **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs,
runs on emulator; permission-aware init + Resume hook; build script; runs on emulator; permission-aware init + Resume hook; build script;
manifest with permissions; this document. manifest with permissions; this document.
- **M1 — metadata**: `db.rs` + `scan.rs`; scan Pictures+DCIM on first launch - **M1 — metadata (DONE, 2026-10-27)**: `db.rs` + `scan.rs`; background scan
(background thread, progress in UI); sqlite table; list of items as a thread → sqlite → `VecModel` in a `ListView` (paths as text). Acceptance
Slint `Model` fed to a placeholder `ListView` (paths as text, no images). verified on emulator: **5002 files in 153 ms**; relaunch `0+ 0~ 0-` in 2 ms;
*Accept: 5k files scanned < 2 s; relaunch reuses DB; mtime-changed files mtime-touch → `1~`, delete → `1-`. Status line shows `N files (a+ u~ d–) in X ms`.
refreshed.* Gotchas hit (also see §6/§12):
- `slint::Result` / `slint::WindowHandle` **do not exist in 1.18.1** as
published on crates.io — desktop bin is `fn main() -> Result<(),
slint::PlatformError>` + `use slint::ComponentHandle;`. (The M0 desktop
bin never actually compiled; only the Android lib was built. The eval
sample `slint_app` is broken the same way.)
- `.slint` modulo is `Math.mod(x, y)`, not `%`.
- ListView rows: `for path[index] in root.items: …` (`index` is not in
scope unless named; `parent` = the ListView).
- `rusqlite(bundled)` needs `CC_<triple>` pointing at NDK clang for
Android — `scripts/build.sh` now exports them.
- Android DB path: `EXTERNAL_DATA_DIR` is **not set** by this
NativeActivity setup and `temp_dir()` = `/data/user/0` (unwritable).
Use `/storage/emulated/0/Android/data/<pkg>/files`, internal files dir
fallback (see `db::android_data_dir`).
- Emulator: grant all-files access once via
`adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow`; it
survives `adb install -r`.
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
or the app can't read them.
- **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache; - **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache;
cancellation; pool; synthetic 5k test set; timing logs. cancellation; pool; synthetic 5k test set; timing logs.
*Accept: first grid fill < 2 s; re-launch fills instantly from disk cache; *Accept: first grid fill < 2 s; re-launch fills instantly from disk cache;

View File

@ -3,9 +3,10 @@
A high-performance image gallery for Android (primary), Linux and macOS — A high-performance image gallery for Android (primary), Linux and macOS —
Rust + [Slint](https://slint.dev) 1.18. Rust + [Slint](https://slint.dev) 1.18.
**Status: M0 (scaffold).** See **[HANDOFF.md](HANDOFF.md)** — the development **Status: M1 (metadata scan + DB) done.** See **[HANDOFF.md](HANDOFF.md)** —
hand-off with the full plan: architecture, thumbnail pipeline, milestones the development hand-off with the full plan: architecture, thumbnail
M1–M7, Slint gotchas, permission flow, and performance targets. pipeline, milestones M1–M7, Slint gotchas, permission flow, and performance
targets. Next: M2 (thumbnail engine).
## Build ## Build

View File

@ -14,6 +14,15 @@ cd "$(dirname "$0")/.."
export ANDROID_HOME=$HOME/android-sdk export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973 export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH" export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite).
# The `cc` crate picks these up per target triple (min sdk 26).
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang
export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang
export CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++
export AR_aarch64_linux_android=$NDK_BIN/llvm-ar
# Debug-keystore signing is fine for personal use / Play internal testing. # Debug-keystore signing is fine for personal use / Play internal testing.
export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android

201
src/db.rs Normal file
View File

@ -0,0 +1,201 @@
//! 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;
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 {
#[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 paths, newest first.
pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error> {
let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?;
let rows = st.query_map([], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
#[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 paths = list_paths(&mut conn).unwrap();
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
}
#[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 paths = list_paths(&mut conn).unwrap();
assert_eq!(paths.len(), 2);
assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap())));
}
}

View File

@ -1,5 +1,63 @@
slint::include_modules!(); slint::include_modules!();
pub mod db;
pub mod scan;
use slint::{ComponentHandle, Model, SharedString, VecModel};
/// M1: kick off the background scan + DB refresh; UI updates are posted to
/// the event loop (the scan thread must never touch the window directly).
/// Called from both the desktop and Android entry points after `Main::new()`.
pub fn start_scan(window: &Main) {
let model = slint::ModelRc::new(VecModel::<SharedString>::default());
window.set_items(model);
let weak = window.as_weak();
std::thread::spawn(move || {
let t0 = std::time::Instant::now();
let roots = scan::roots();
let entries = scan::walk(&roots, &|n| {
let weak = weak.clone();
let msg = format!("Scanning… {}", n);
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = weak.upgrade() {
w.set_status(msg.into());
}
});
});
let mut db = match db::open(&db::db_path()) {
Ok(db) => db,
Err(e) => {
eprintln!("gallery: db error: {e}");
let _ = slint::invoke_from_event_loop( move || {
if let Some(w) = weak.upgrade() {
w.set_status(format!("db error: {e}").into());
}
});
return;
}
};
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
eprintln!("gallery: db apply error: {e}");
(0, 0, 0)
});
let paths = db::list_paths(&mut db).unwrap_or_default();
let n = paths.len();
let ms = t0.elapsed().as_millis();
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() {
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>());
}
w.set_status(format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms").into());
});
});
}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use std::sync::OnceLock; use std::sync::OnceLock;
@ -39,5 +97,6 @@ fn android_main(app: slint::android::AndroidApp) {
.unwrap(); .unwrap();
let main_window = Main::new().unwrap(); let main_window = Main::new().unwrap();
start_scan(&main_window);
main_window.run().unwrap(); main_window.run().unwrap();
} }

View File

@ -1,7 +1,9 @@
use gallery::Main; use gallery::Main;
use slint::ComponentHandle;
fn main() -> slint::Result { fn main() -> Result<(), slint::PlatformError> {
let main_window = Main::new()?; let main_window = Main::new()?;
gallery::start_scan(&main_window);
main_window.show()?; main_window.show()?;
main_window.run() main_window.run()
} }

View File

@ -1,10 +1,14 @@
// Gallery — M0 scaffold. Real UI arrives with M3 (grid) per HANDOFF.md. // Gallery — M1: metadata scan + placeholder list.
import { VerticalBox } from "std-widgets.slint"; // The real grid (image cells) arrives with M3 per HANDOFF.md.
import { ListView, VerticalBox } from "std-widgets.slint";
export component Main inherits Window { export component Main inherits Window {
title: "Gallery"; title: "Gallery";
in property <string> status: "M0 scaffold — see HANDOFF.md"; /// Scan/DB status line ("Scanning… 1234", "5123 files (42+ 3~ 1–) in 380 ms").
in property <string> status: "Scanning…";
/// Scanned file paths, newest first (M1: shown as text rows).
in property <[string]> items;
VerticalBox { VerticalBox {
width: 100%; width: 100%;
@ -21,12 +25,24 @@ export component Main inherits Window {
font-weight: 600; font-weight: 600;
} }
Text { text: "\{status}"; font-size: 14px; } Text { text: "\{status}"; font-size: 14px; }
// M3: the virtualized grid (ListView) goes here.
Rectangle { // M1: paths as text. M3 replaces the row body with square image cells.
ListView {
width: 100%; width: 100%;
vertical-stretch: 1; vertical-stretch: 1;
background: #222; for path[index] in root.items: Rectangle {
border-radius: 8px; height: 28px;
width: parent.width;
background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424;
border-radius: 4px;
Text {
x: 8px;
text: path;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
} }
} }
} }

136
src/scan.rs Normal file
View File

@ -0,0 +1,136 @@
//! Directory walk + image detection (M1).
//!
//! Plain recursive scan of the roots (Pictures + DCIM on Android). This is
//! what makes the app testable without MediaStore; MediaStore integration is
//! a later refinement (HANDOFF.md §7.4).
use std::path::{Path, PathBuf};
/// One image on disk. `mtime` is nanoseconds since the Unix epoch (i64).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub path: String,
pub mtime: i64,
pub size: i64,
}
/// Lower-case image extensions we index. HEIC is the Android camera default.
pub const IMAGE_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff",
];
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
pub fn roots() -> Vec<PathBuf> {
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
return d
.split(':')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
}
#[cfg(target_os = "android")]
{
let ext = PathBuf::from("/storage/emulated/0");
vec![ext.join("Pictures"), ext.join("DCIM")]
}
#[cfg(not(target_os = "android"))]
{
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
vec![home.join("Pictures"), home.join("DCIM")]
}
}
/// Recursively collect all image entries below `roots`.
/// `on_progress(n)` is called every 512 files found.
pub fn walk(roots: &[PathBuf], on_progress: &dyn Fn(usize)) -> Vec<Entry> {
let mut out = Vec::new();
for r in roots {
if r.is_dir() {
walk_dir(r, &mut out, on_progress);
}
}
out
}
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_progress: &dyn Fn(usize)) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for ent in rd.flatten() {
let p = ent.path();
let Ok(ft) = ent.file_type() else { continue };
if ft.is_dir() {
// Don't follow symlinked dirs (loop protection).
if !p.is_symlink() {
walk_dir(&p, out, on_progress);
}
continue;
}
if !ft.is_file() {
continue;
}
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { continue };
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
continue;
}
let Ok(md) = std::fs::metadata(&p) else { continue };
let mtime = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
out.push(Entry {
path: p.to_string_lossy().into_owned(),
mtime,
size: md.len() as i64,
});
if out.len() % 512 == 0 {
on_progress(out.len());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_tree(root: &Path, n_files: usize, subdirs: usize) {
for d in 0..subdirs {
let dir = root.join(format!("dir{d}"));
std::fs::create_dir_all(&dir).unwrap();
}
for i in 0..n_files {
let dir = if i % 2 == 0 { root.join(format!("dir{}", i % subdirs)) } else { root.to_path_buf() };
let mut f = std::fs::File::create(dir.join(format!("img{i}.jpg"))).unwrap();
f.write_all(&vec![0u8; i % 64]).unwrap();
}
}
#[test]
fn finds_images_skips_others() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.jpg"), b"x").unwrap();
std::fs::write(dir.path().join("B.PNG"), b"x").unwrap();
std::fs::write(dir.path().join("c.txt"), b"x").unwrap();
std::fs::create_dir_all(dir.path().join("d")).unwrap();
std::fs::write(dir.path().join("d").join("e.heic"), b"x").unwrap();
let out = walk(&[dir.path().to_path_buf()], &|_| {});
assert_eq!(out.len(), 3);
assert!(out.iter().all(|e| e.size == 1));
}
#[test]
fn accepts_5k_files_under_2s() {
let dir = tempfile::tempdir().unwrap();
make_tree(dir.path(), 5000, 8);
let t = std::time::Instant::now();
let out = walk(&[dir.path().to_path_buf()], &|_| {});
let elapsed = t.elapsed();
assert_eq!(out.len(), 5000);
assert!(
elapsed.as_millis() < 2000,
"scan took {:?} (> 2 s)",
elapsed
);
}
}