- 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.
133 lines
5.0 KiB
Rust
133 lines
5.0 KiB
Rust
slint::include_modules!();
|
||
|
||
pub mod db;
|
||
pub mod scan;
|
||
|
||
use slint::{ComponentHandle, Model, SharedString, VecModel};
|
||
|
||
/// Fill the window's list model from `paths` and set the status line.
|
||
fn set_list(weak: &slint::Weak<Main>, paths: Vec<String>, status: String) {
|
||
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(status.into());
|
||
});
|
||
}
|
||
|
||
/// Set only the status line (never touches the model).
|
||
fn set_status(weak: &slint::Weak<Main>, status: String) {
|
||
let weak = weak.clone();
|
||
let _ = slint::invoke_from_event_loop(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
w.set_status(status.into());
|
||
}
|
||
});
|
||
}
|
||
|
||
/// M1: metadata from sqlite paints the list immediately (first frame < 100 ms
|
||
/// per HANDOFF §8); a background walk then refreshes the DB and the list.
|
||
/// UI updates are posted to the event loop — the scan thread never touches the
|
||
/// window directly. Called from both 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 mut db = match db::open(&db::db_path()) {
|
||
Ok(db) => db,
|
||
Err(e) => {
|
||
eprintln!("gallery: db error: {e}");
|
||
set_list(&weak, Vec::new(), format!("db error: {e}"));
|
||
return;
|
||
}
|
||
};
|
||
|
||
// 1. Instant first paint from the cached DB (relaunch path).
|
||
let cached = db::list_paths(&mut db).unwrap_or_default();
|
||
let n0 = cached.len();
|
||
let db_warm = n0 > 0;
|
||
if db_warm {
|
||
set_list(&weak, cached, format!("{n0} files (cached)"));
|
||
}
|
||
|
||
// 2. Background rescan: detect new/changed/removed files.
|
||
// Cold start: stream results into the list as they're found, so the
|
||
// UI fills in while the scan runs (never waits for the full DB).
|
||
// Warm start: keep the cached list, only update the status line.
|
||
let t0 = std::time::Instant::now();
|
||
let roots = scan::roots();
|
||
let entries = scan::walk_stream(&roots, &|batch| {
|
||
let n = batch.len();
|
||
if db_warm {
|
||
set_status(&weak, format!("Updating… {n}"));
|
||
} else {
|
||
let mut sorted = batch.to_vec();
|
||
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
|
||
set_list(
|
||
&weak,
|
||
sorted.into_iter().map(|e| e.path).collect(),
|
||
format!("Scanning… {n}"),
|
||
);
|
||
}
|
||
});
|
||
|
||
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");
|
||
|
||
set_list(&weak, paths, format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms"));
|
||
});
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
use std::sync::OnceLock;
|
||
|
||
/// A clone of `AndroidApp` kept for the app's whole lifetime so the Java side
|
||
/// stays reachable from ANY thread after Slint has consumed the original:
|
||
/// `run_on_java_main_thread`, `vm_as_ptr()` (JVM), `activity_as_ptr()` (Activity).
|
||
/// This is the substrate for the storage-permission flow (HANDOFF.md §7) and,
|
||
/// later, anything that needs JNI.
|
||
#[cfg(target_os = "android")]
|
||
static ANDROID_APP: OnceLock<slint::android::AndroidApp> = OnceLock::new();
|
||
|
||
#[cfg(target_os = "android")]
|
||
pub fn android_app() -> Option<&'static slint::android::AndroidApp> {
|
||
ANDROID_APP.get()
|
||
}
|
||
|
||
/// Android entry point.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
fn android_main(app: slint::android::AndroidApp) {
|
||
android_logger::init_once(
|
||
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
|
||
);
|
||
let _ = ANDROID_APP.set(app.clone());
|
||
|
||
// `init_with_event_listener` gives us the raw MainEvent stream
|
||
// (Start/Resume/Pause/Stop/SaveState/ConfigChanged/...) while Slint owns
|
||
// the event loop. This is Slint's one first-class Android lifecycle hook:
|
||
// M5 re-checks storage permission on Resume (user returned from Settings).
|
||
slint::android::init_with_event_listener(app, |event| {
|
||
use slint::android::android_activity::{MainEvent, PollEvent};
|
||
if let PollEvent::Main(MainEvent::Resume { .. } | MainEvent::Start { .. }) = event {
|
||
log::info!("gallery: app resumed");
|
||
// TODO(M5): storage::recheck_permission()
|
||
}
|
||
})
|
||
.unwrap();
|
||
|
||
let main_window = Main::new().unwrap();
|
||
start_scan(&main_window);
|
||
main_window.run().unwrap();
|
||
}
|