- relaunch: DB list paints immediately ('N files (cached)'), background
walk refreshes ('Updating… N') — no more 5.5 s blank list on warm start
- first launch: walk_stream() feeds the list in 512-file batches while
scanning; UI never waits for the full DB build
- skip hidden dirs (.thumbnails/.trash/…): -559 entries on the phone
- db.rs: cfg-gate ANDROID_PACKAGE (dead-code warning on desktop)
Phone (8.5k photos): cold scan 4.2 s with live list; warm relaunch
paints instantly, rescan 4.2 s in background.
149 lines
4.9 KiB
Rust
149 lines
4.9 KiB
Rust
//! 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> {
|
|
walk_stream(roots, &|batch| on_progress(batch.len()))
|
|
}
|
|
|
|
/// Like `walk`, but streams progress: `on_batch(&entries)` receives ALL
|
|
/// entries collected so far every 512 new files, so the UI can show results
|
|
/// while the scan is still running (first launch must not wait for the full
|
|
/// DB build).
|
|
pub fn walk_stream(roots: &[PathBuf], on_batch: &dyn Fn(&[Entry])) -> Vec<Entry> {
|
|
let mut out = Vec::new();
|
|
for r in roots {
|
|
if r.is_dir() {
|
|
walk_dir(r, &mut out, on_batch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_batch: &dyn Fn(&[Entry])) {
|
|
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() {
|
|
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
|
|
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
|
|
continue;
|
|
}
|
|
// Don't follow symlinked dirs (loop protection).
|
|
if !p.is_symlink() {
|
|
walk_dir(&p, out, on_batch);
|
|
}
|
|
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_batch(out);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
}
|