gallery/src/scan.rs
Greg Pomerantz c72b00f7ec M1: instant list paint + streaming first scan
- 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.
2026-10-27 13:45:37 -04:00

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
);
}
}