Files
DarkRoom/ui/dr-ui/src/import_ui.rs
T
dtourolle caae65c78d Import from an SD card or card reader on Android
Import was switched off on Android: `imports_supported` was true only for
`target_os = "linux"`, and its comment said Android has no path to read a
card by and nowhere to write the copies. Neither holds. With "all files
access" (MANAGE_EXTERNAL_STORAGE, API 30) an app reads the root of an SD
card or a USB card reader by path, `/storage/9C33-6BBD`, and the importer
only ever writes into its own staging directory, which is a plain
directory on Android too. So the engine runs unchanged; what was missing
was finding the card and the permission.

- The manifest declares MANAGE_EXTERNAL_STORAGE, and
  READ_EXTERNAL_STORAGE up to API 29 with requestLegacyExternalStorage,
  which is the same access on 28 and 29.
- Cards.java lists the mounted non-primary volumes through
  StorageManager and opens the system "All files access" page for this
  app. dr_ui::cards is the JNI bridge, through saf's helpers.
- The import page on Android asks for the permission with an "Allow
  access" button until it has it, rather than showing an empty list that
  reads as "no card", and watches for the grant so the list fills in when
  the user comes back from settings.

Google Play restricts this permission to file managers and the like;
DarkRoom is sideloaded, so that does not apply.
2026-09-30 21:30:09 -04:00

974 lines
34 KiB
Rust

//! TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
//! The import page: state, and the callbacks that move it.
//!
//! Split from [`crate::import`] the way `library_ui` is split from `library`:
//! that module knows how to bring a card in and nothing about windows, this one
//! knows about windows and nothing about copying.
//!
//! # `render` is the only writer of window properties
//!
//! Every callback changes the controller and then calls [`render`], rather than
//! setting the one property it touched. The settings page works the same way
//! and for the same reason: several of these properties are derived from
//! others — whether Import can be pressed depends on the source, the
//! destination and whether a run is going — and a callback that updated only
//! its own field would leave the derived ones describing a state that has
//! passed.
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use slint::ComponentHandle;
use dr_ingest::{DuplicatePolicy, Options, TransferMode, DEFAULT_TEMPLATE};
use dr_types::{FormatFilter, ImportSettings};
use crate::activity::{Activity, ActivityLog, Kind};
use crate::import::{self, Message, Request, Upload};
use crate::{AppWindow, Page};
/// How often the drain runs while an import is going.
///
/// The worker sends one message per file, and a card is two thousand files: at
/// 120ms a burst of completions costs one redraw rather than a hundred, and a
/// bar still looks live. The same interval the activity register publishes at.
const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120);
/// What the caller has to supply that this module cannot know.
///
/// A closure returning this rather than fields on the controller: an account
/// can be signed out and a library re-opened while the page is on screen, and
/// a value captured when the page was wired would be the *old* library.
pub struct Context {
/// The catalog, for duplicate detection (FR-CAT-11).
pub catalog: PathBuf,
/// The signed-in account, where there is one. `None` means the import is
/// local-only, which is a perfectly good way to use the application.
pub upload: Option<Upload>,
/// The library folder on the server.
///
/// Shown on the page, because once an account is signed in "where do these
/// end up" has two answers, and giving only the local one answers half the
/// question the Import button is about to commit to.
pub library_label: String,
/// Which file types to take off a card.
pub filter: FormatFilter,
}
/// Everything the import page renders from.
pub struct ImportController {
/// Persisted defaults, shared with the settings page so both write one
/// file. An import's options are remembered between cards.
settings: Rc<crate::settings_ui::SettingsController>,
activity: Rc<ActivityLog>,
volumes: RefCell<Vec<dr_plat::Volume>>,
/// Which volume is selected, or -1 for a typed path.
selected: Cell<i32>,
/// The source. Typed or chosen; a card the app did not find is still a card.
card: RefCell<String>,
/// Android only: the user has not granted "all files access", so no card
/// can be read and the page asks for it instead of listing volumes.
needs_access: Cell<bool>,
/// Android only: watches for that grant after the settings page has been
/// opened, so the list fills in when the user comes back.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
access_watch: RefCell<Option<slint::Timer>>,
/// What the last survey found, `None` before one has run.
survey: RefCell<Option<(usize, u64)>>,
surveying: Cell<bool>,
/// The server folder originals are uploaded to, as the page shows it.
///
/// Cached when the page opens rather than fetched in `render`: the context
/// closure reaches the account, and `render` runs on every keystroke.
upload_target: RefCell<String>,
running: Cell<bool>,
fraction: Cell<f32>,
status: RefCell<String>,
error: RefCell<String>,
/// Set to stop the worker. Replaced per run.
cancel: RefCell<Option<import::Cancel>>,
/// Held here rather than in the function that starts it: a `slint::Timer`
/// stops the moment it is dropped, so a timer local to `start` would be
/// collected before the first message arrived.
timer: RefCell<Option<slint::Timer>>,
/// The register row, dropped when the run ends. See `activity`'s docs on
/// why a job that stops reporting cannot hang the bar.
job: RefCell<Option<Activity>>,
}
impl ImportController {
pub fn new(
settings: Rc<crate::settings_ui::SettingsController>,
activity: Rc<ActivityLog>,
) -> Rc<Self> {
Rc::new(Self {
settings,
activity,
volumes: RefCell::new(Vec::new()),
selected: Cell::new(-1),
card: RefCell::new(String::new()),
needs_access: Cell::new(false),
access_watch: RefCell::new(None),
survey: RefCell::new(None),
surveying: Cell::new(false),
upload_target: RefCell::new(String::new()),
running: Cell::new(false),
fraction: Cell::new(0.0),
status: RefCell::new(String::new()),
error: RefCell::new(String::new()),
cancel: RefCell::new(None),
timer: RefCell::new(None),
job: RefCell::new(None),
})
}
/// The import section of the stored settings.
fn options(&self) -> ImportSettings {
self.settings.settings.borrow().import.clone()
}
/// Change a stored option and write the file.
fn edit(&self, f: impl FnOnce(&mut ImportSettings)) {
let mut settings = self.settings.settings.borrow_mut();
f(&mut settings.import);
let to_save = settings.clone();
drop(settings);
if let Err(e) = self.settings.store.save(&to_save) {
*self.error.borrow_mut() = format!("could not save the import settings: {e}");
}
}
/// Look for cards. Cheap, and safe to call whenever the page is shown.
fn refresh_volumes(&self) {
let found = self.find_volumes();
// Keep a typed path: a refresh must not discard what the user entered
// because the app happened to find three other volumes.
if self.card.borrow().is_empty() {
if let Some(first) = found.iter().position(|v| v.is_likely_card()) {
self.selected.set(first as i32);
*self.card.borrow_mut() = found[first].path.display().to_string();
}
} else {
// Re-point the selection at whichever volume the path belongs to,
// so a remembered path still highlights its row.
let card = self.card.borrow().clone();
self.selected.set(
found
.iter()
.position(|v| v.path.display().to_string() == card)
.map(|i| i as i32)
.unwrap_or(-1),
);
}
*self.volumes.borrow_mut() = found;
}
/// The mount table, on the desktop.
#[cfg(not(target_os = "android"))]
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
dr_plat::volumes()
}
/// `StorageManager`, on Android, once the user has allowed the app to read
/// a card at all (see [`crate::cards`]).
///
/// The path cannot be typed here — the field is hidden on Android — so a
/// remembered path whose card has been taken out is dropped rather than
/// left selected with no way to change it.
#[cfg(target_os = "android")]
fn find_volumes(&self) -> Vec<dr_plat::Volume> {
let granted = crate::cards::has_access();
self.needs_access.set(!granted);
let found = if granted {
crate::cards::volumes()
} else {
Vec::new()
};
let card = self.card.borrow().clone();
if !found.iter().any(|v| v.path.display().to_string() == card) {
self.card.borrow_mut().clear();
}
found
}
/// The transfer options a run is started with.
fn ingest_options(&self) -> Options {
let stored = self.options();
Options {
folder_template: template_of(&stored),
mode: if stored.move_from_card {
TransferMode::Move
} else {
TransferMode::Copy
},
on_duplicate: if stored.skip_duplicates {
DuplicatePolicy::Skip
} else {
DuplicatePolicy::ImportAsNew
},
verify: stored.verify,
}
}
/// Whether Import can be pressed.
///
/// Three things have to be true and all three are recoverable by the user,
/// which is why the button is disabled rather than the press being refused
/// with a message: a disabled button beside a filled-in form says which
/// field is missing, where a dialogue says it after the fact.
fn can_start(&self) -> bool {
!self.running.get()
&& !self.card.borrow().is_empty()
// An account, because the library is on the server and an import
// with none has nowhere to go at all.
&& !self.upload_target.borrow().is_empty()
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
}
}
/// The template a run uses: the stored one, or the layout's default.
///
/// Empty means "the default" rather than "no folders" — see `ImportSettings`.
fn template_of(stored: &ImportSettings) -> String {
if stored.folder_template.trim().is_empty() {
DEFAULT_TEMPLATE.to_string()
} else {
stored.folder_template.clone()
}
}
/// What the template does, shown against a real date.
///
/// Uses *today* rather than a fixed example: a photographer importing today's
/// shoot recognises today's folder, and a preview showing 2026-08-22 forever
/// reads as a placeholder nobody has to check.
fn preview(stored: &ImportSettings, now: i64) -> String {
let shot = dr_ingest::Shot {
captured_at: Some(now),
make: Some("Canon".into()),
model: Some("EOS R5".into()),
..Default::default()
};
dr_ingest::expand(&template_of(stored), &shot).display()
}
/// Push the whole controller into the window.
pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
let stored = ctl.options();
let volumes = ctl.volumes.borrow();
let labels: Vec<slint::SharedString> =
volumes.iter().map(|v| v.label.as_str().into()).collect();
let details: Vec<slint::SharedString> =
volumes.iter().map(|v| describe_volume(v).into()).collect();
window.set_import_volume_labels(slint::ModelRc::new(slint::VecModel::from(labels)));
window.set_import_volume_details(slint::ModelRc::new(slint::VecModel::from(details)));
window.set_import_volume_selected(ctl.selected.get());
let card = ctl.card.borrow().clone();
window.set_import_card_looks_right(
!card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)),
);
window.set_import_card_path(card.into());
window.set_import_needs_access(ctl.needs_access.get());
window.set_import_surveying(ctl.surveying.get());
window.set_import_survey_summary(
match *ctl.survey.borrow() {
Some((0, _)) => "Nothing here to import".to_string(),
Some((n, bytes)) => format!(
"{n} photograph{} · {}",
if n == 1 { "" } else { "s" },
crate::activity::describe_bytes(bytes)
),
None => String::new(),
}
.into(),
);
// The only destination there is. Empty means no account, which is what
// keeps Import disabled — see `can_start`.
window.set_import_upload_target(ctl.upload_target.borrow().as_str().into());
window.set_import_folder_template(stored.folder_template.as_str().into());
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
window.set_import_mode_labels(slint::ModelRc::new(slint::VecModel::from(vec![
slint::SharedString::from("Copy"),
slint::SharedString::from("Move"),
])));
window.set_import_mode_selected(if stored.move_from_card { 1 } else { 0 });
window.set_import_mode_is_move(stored.move_from_card);
window.set_import_duplicate_labels(slint::ModelRc::new(slint::VecModel::from(vec![
slint::SharedString::from("Skip"),
slint::SharedString::from("Import again"),
])));
window.set_import_duplicate_selected(if stored.skip_duplicates { 0 } else { 1 });
window.set_import_verify(stored.verify);
window.set_import_backup_on(!stored.backup.is_empty());
window.set_import_backup_path(stored.backup.as_str().into());
window.set_import_running(ctl.running.get());
window.set_import_fraction(ctl.fraction.get());
window.set_import_status(ctl.status.borrow().as_str().into());
window.set_import_error(ctl.error.borrow().as_str().into());
window.set_import_can_start(ctl.can_start());
}
/// Why a volume is being offered.
fn describe_volume(v: &dr_plat::Volume) -> String {
let what = if v.has_dcim {
"camera card"
} else if v.removable {
"removable volume"
} else {
"volume"
};
format!("{what} · {}", v.path.display())
}
/// Connect the page's callbacks.
///
/// `context` is asked for afresh on every run rather than captured, because the
/// account and library it describes can change while the page is open.
/// `on_imported` runs after a successful import so the caller can rescan — this
/// module does not catalogue what it wrote (see [`crate::import`]).
pub fn wire<C, F>(window: &AppWindow, ctl: Rc<ImportController>, context: C, on_imported: F)
where
C: Fn() -> Option<Context> + 'static,
F: Fn() + 'static,
{
let context = Rc::new(context);
let on_imported = Rc::new(on_imported);
wire_opening_and_closing(window, &ctl, &context);
wire_choosing_a_source(window, &ctl, &context);
wire_options(window, &ctl);
wire_running(window, &ctl, &context, &on_imported);
}
/// Opening and closing.
fn wire_opening_and_closing<C>(window: &AppWindow, ctl: &Rc<ImportController>, context: &Rc<C>)
where
C: Fn() -> Option<Context> + 'static,
{
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
window.on_import_open(move || {
let Some(w) = weak.upgrade() else { return };
// The button that reaches here is hidden where this is false, so
// arriving anyway means a key binding or a stale window state.
// Refused rather than shown empty.
if !dr_plat::imports_supported() {
return;
}
// Re-read the settings file for the same reason the settings page
// does: another instance may have written it since.
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
*ctl.upload_target.borrow_mut() =
context().map(|c| c.library_label).unwrap_or_default();
ctl.refresh_volumes();
*ctl.status.borrow_mut() = String::new();
*ctl.error.borrow_mut() = String::new();
survey(&w, &ctl, &context);
render(&w, &ctl);
w.set_active_page(Page::Import);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_close(move || {
let Some(w) = weak.upgrade() else { return };
// The header disables Back while a run is going, so reaching here
// mid-import would take the only view of the work off screen.
if ctl.running.get() {
return;
}
w.set_active_page(Page::None);
});
}
}
/// Choosing a source.
fn wire_choosing_a_source<C>(window: &AppWindow, ctl: &Rc<ImportController>, context: &Rc<C>)
where
C: Fn() -> Option<Context> + 'static,
{
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
window.on_import_volume_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
let path = ctl
.volumes
.borrow()
.get(i as usize)
.map(|v| v.path.display().to_string());
if let Some(path) = path {
ctl.selected.set(i);
*ctl.card.borrow_mut() = path;
survey(&w, &ctl, &context);
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
window.on_import_card_path_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
set_card(&w, &ctl, &context, text.to_string());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
window.on_import_choose_card(move || {
let Some(w) = weak.upgrade() else { return };
let start = ctl.card.borrow().clone();
let (weak, ctl, context) = (weak.clone(), ctl.clone(), context.clone());
crate::folder_dialog::ask(
&w,
"Import from",
crate::folder_dialog::Pick::Folder,
Some(&start),
move |path| {
let Some(w) = weak.upgrade() else { return };
set_card(&w, &ctl, &context, path.to_string_lossy().into_owned());
},
);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_refresh_volumes(move || {
let Some(w) = weak.upgrade() else { return };
ctl.refresh_volumes();
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
window.on_import_grant_access(move || {
#[cfg(target_os = "android")]
watch_for_access(&weak, &ctl, &context);
// Nothing to grant elsewhere; the control is shown only when
// `needs_access` is set, which only Android sets.
#[cfg(not(target_os = "android"))]
let _ = (&weak, &ctl, &context);
});
}
}
/// Options.
fn wire_options(window: &AppWindow, ctl: &Rc<ImportController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_template_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.folder_template = text.to_string());
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_mode_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.move_from_card = i == 1);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_duplicate_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.skip_duplicates = i == 0);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_verify_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.verify = on);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_backup_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
// Turning it off clears the path rather than remembering it: a
// second destination that reappears when the box is re-ticked is
// a stale folder somebody unplugged months ago.
ctl.edit(|s| {
if !on {
s.backup.clear();
} else if s.backup.is_empty() {
s.backup = " ".into();
}
});
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_backup_path_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.backup = text.to_string());
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_choose_backup(move || {
let Some(w) = weak.upgrade() else { return };
let start = ctl
.settings
.settings
.borrow()
.import
.backup
.trim()
.to_string();
let (weak, ctl) = (weak.clone(), ctl.clone());
crate::folder_dialog::ask(
&w,
"Second copy in",
crate::folder_dialog::Pick::Folder,
Some(&start),
move |path| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.backup = path.to_string_lossy().into_owned());
render(&w, &ctl);
},
);
});
}
}
/// Running.
fn wire_running<C, F>(
window: &AppWindow,
ctl: &Rc<ImportController>,
context: &Rc<C>,
on_imported: &Rc<F>,
) where
C: Fn() -> Option<Context> + 'static,
F: Fn() + 'static,
{
{
let weak = window.as_weak();
let ctl = ctl.clone();
let context = context.clone();
let on_imported = on_imported.clone();
window.on_import_start(move || {
let Some(w) = weak.upgrade() else { return };
start(&w, &ctl, &context, &on_imported);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_import_stop(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(cancel) = ctl.cancel.borrow().as_ref() {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
*ctl.status.borrow_mut() = "Stopping after the current file…".into();
render(&w, &ctl);
});
}
}
/// Take a source the user named — typed, or chosen in the dialogue — rather
/// than one from the volume list, and count what is on it.
/// Send the user to the system page that grants "all files access", and
/// fill the page in once they have.
///
/// The grant is a settings switch, not a dialog with an answer, and nothing
/// tells the app when it flips. So the page asks again twice a second until
/// it has it, or for five minutes — long enough to find the switch, short
/// enough that a user who walked away is not polled for ever. Coming back to
/// the page later re-checks anyway (`import_open`).
#[cfg(target_os = "android")]
fn watch_for_access<C>(weak: &slint::Weak<AppWindow>, ctl: &Rc<ImportController>, context: &Rc<C>)
where
C: Fn() -> Option<Context> + 'static,
{
crate::cards::request_access();
let since = std::time::Instant::now();
let (weak, held, context) = (weak.clone(), ctl.clone(), context.clone());
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(500),
move || {
let granted = crate::cards::has_access();
if !granted && since.elapsed() < std::time::Duration::from_secs(300) {
return;
}
if let Some(t) = held.access_watch.borrow().as_ref() {
t.stop();
}
// Released outside its own callback, as `saf::pick_tree` does: a
// timer dropped from inside the closure it is running is dropping
// that closure mid-call.
let release = held.clone();
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
release.access_watch.borrow_mut().take();
});
if granted {
let Some(w) = weak.upgrade() else { return };
held.refresh_volumes();
survey(&w, &held, &context);
render(&w, &held);
}
},
);
*ctl.access_watch.borrow_mut() = Some(timer);
}
fn set_card(
w: &AppWindow,
ctl: &Rc<ImportController>,
context: &Rc<impl Fn() -> Option<Context>>,
path: String,
) {
*ctl.card.borrow_mut() = path;
ctl.selected.set(-1);
survey(w, ctl, context);
render(w, ctl);
}
/// Count what is on the card, without copying anything.
///
/// Runs on the UI thread, which is defensible only because it is a directory
/// walk with no reads: a full card is a couple of thousand `stat` calls. If a
/// slow reader ever makes this visible, it moves to the worker beside the
/// import — the message type already has a `Surveyed` variant for it.
fn survey(
window: &AppWindow,
ctl: &Rc<ImportController>,
context: &Rc<impl Fn() -> Option<Context>>,
) {
*ctl.survey.borrow_mut() = None;
let card = ctl.card.borrow().clone();
if card.is_empty() {
return;
}
let Some(context) = context() else { return };
let root = dr_types::RootId(9001);
let storage = dr_plat::LocalStorage::with_root(root, PathBuf::from(&card));
match import::survey(&storage, root, &context.filter, &|| false) {
Ok(found) => {
let bytes = found.iter().map(|c| c.size).sum();
*ctl.survey.borrow_mut() = Some((found.len(), bytes));
}
Err(e) => {
*ctl.error.borrow_mut() = format!("could not read {card}: {e}");
}
}
render(window, ctl);
}
/// Begin an import.
fn start(
window: &AppWindow,
ctl: &Rc<ImportController>,
context: &Rc<impl Fn() -> Option<Context> + 'static>,
on_imported: &Rc<impl Fn() + 'static>,
) {
if ctl.running.get() {
return;
}
let Some(context) = context() else {
*ctl.error.borrow_mut() = "no library is open".into();
render(window, ctl);
return;
};
let stored = ctl.options();
let backup = if stored.backup.trim().is_empty() {
None
} else {
Some(PathBuf::from(stored.backup.trim()))
};
let Some(upload) = context.upload else {
*ctl.error.borrow_mut() =
"Sign in first — the library this imports into is on the server.".into();
render(window, ctl);
return;
};
let request = Request {
card: PathBuf::from(ctl.card.borrow().clone()),
staging: upload.staging.clone(),
backup,
catalog: context.catalog.clone(),
filter: context.filter.clone(),
options: ctl.ingest_options(),
upload,
decoder: dr_decode::default(),
};
let cancel: import::Cancel = Default::default();
let rx = import::spawn(request, cancel.clone());
ctl.running.set(true);
ctl.fraction.set(0.0);
*ctl.cancel.borrow_mut() = Some(cancel);
*ctl.error.borrow_mut() = String::new();
*ctl.status.borrow_mut() = "Reading the card…".into();
*ctl.job.borrow_mut() = Some(ctl.activity.begin(Kind::Download, "Importing"));
render(window, ctl);
let weak = window.as_weak();
let on_imported = on_imported.clone();
let timer = slint::Timer::default();
{
// The closure owns its own handle: the timer outlives this function,
// and `ctl` below still has to store it.
let ctl = ctl.clone();
timer.start(slint::TimerMode::Repeated, DRAIN_INTERVAL, move || {
let Some(w) = weak.upgrade() else { return };
drain(&w, &ctl, &rx, &on_imported);
});
}
*ctl.timer.borrow_mut() = Some(timer);
}
/// Take everything waiting on the channel and reflect it.
fn drain(
window: &AppWindow,
ctl: &Rc<ImportController>,
rx: &std::sync::mpsc::Receiver<Message>,
on_imported: &Rc<impl Fn()>,
) {
let mut finished = None;
// `try_iter` rather than one message per tick: a card completes files
// faster than the timer fires, and draining one at a time would leave the
// bar minutes behind the work.
for message in rx.try_iter() {
match message {
Message::Surveyed { files, bytes } => {
*ctl.survey.borrow_mut() = Some((files, bytes));
ctl.surveying.set(false);
if let Some(job) = ctl.job.borrow().as_ref() {
job.total(files);
}
}
Message::Progress {
done,
total,
bytes,
name,
} => {
ctl.fraction.set(if total == 0 {
0.0
} else {
done as f32 / total as f32
});
*ctl.status.borrow_mut() = format!(
"{done} of {total} — {name} ({})",
crate::activity::describe_bytes(bytes)
);
if let Some(job) = ctl.job.borrow().as_ref() {
job.progress(done, total);
}
}
Message::Uploading { done, total } => {
ctl.fraction.set(if total == 0 {
0.0
} else {
done as f32 / total as f32
});
*ctl.status.borrow_mut() = format!("Uploading {} of {total}…", done + 1);
if let Some(job) = ctl.job.borrow().as_ref() {
job.detail(format!("uploading {} of {total}", done + 1));
}
}
Message::Finished(outcome) => finished = Some(Ok(outcome)),
Message::Failed(why) => finished = Some(Err(why)),
}
}
let Some(result) = finished else {
render(window, ctl);
return;
};
// Stop before reporting: the timer holds the receiver, and a tick after
// the channel closes would spin for the rest of the session.
ctl.timer.borrow_mut().take();
ctl.cancel.borrow_mut().take();
ctl.running.set(false);
ctl.fraction.set(0.0);
match result {
Ok(outcome) => {
let text = import::describe(&outcome);
if let Some(job) = ctl.job.borrow_mut().take() {
job.finish(text.clone());
}
*ctl.status.borrow_mut() = text;
// Only when something arrived: a run that imported nothing has
// nothing for a scan to find, and a rescan of a large library is
// not free.
if outcome.imported > 0 {
on_imported();
}
}
Err(why) => {
if let Some(job) = ctl.job.borrow_mut().take() {
job.fail(why.clone());
}
*ctl.error.borrow_mut() = why;
}
}
render(window, ctl);
}
#[cfg(test)]
mod tests {
use super::*;
/// 2026-08-22T14:00:00Z.
const AUG_22: i64 = 1_787_407_200;
fn stored() -> ImportSettings {
ImportSettings::default()
}
#[test]
fn an_unset_template_falls_back_to_the_layouts_default() {
// The default lives in one place — the expander — and empty here means
// "whatever that is".
assert_eq!(template_of(&stored()), DEFAULT_TEMPLATE);
}
#[test]
fn a_chosen_template_is_used_as_written() {
let s = ImportSettings {
folder_template: "{yyyy}/{make}".into(),
..stored()
};
assert_eq!(template_of(&s), "{yyyy}/{make}");
}
#[test]
fn whitespace_is_not_a_template() {
// A field cleared by selecting and deleting often leaves a space
// behind, and a folder called " " is not what anybody meant.
let s = ImportSettings {
folder_template: " ".into(),
..stored()
};
assert_eq!(template_of(&s), DEFAULT_TEMPLATE);
}
#[test]
fn the_preview_shows_what_the_template_will_do() {
assert_eq!(preview(&stored(), AUG_22), "2026/2026-08-22");
let s = ImportSettings {
folder_template: "{yyyy}/{model}".into(),
..stored()
};
assert_eq!(preview(&s, AUG_22), "2026/EOS R5");
}
#[test]
fn an_import_needs_an_account_before_it_can_start() {
// The library is on the server, so "where does this go" has no local
// answer to fall back on. Import stays disabled rather than quietly
// filling a folder nothing ever shows.
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.survey.borrow_mut() = Some((12, 1234));
assert!(!ctl.can_start(), "no account, yet Import was offered");
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
assert!(ctl.can_start());
}
#[test]
fn an_empty_card_cannot_be_imported_either() {
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
*ctl.survey.borrow_mut() = Some((0, 0));
assert!(!ctl.can_start());
}
#[test]
fn a_volume_says_why_it_is_being_offered() {
// "EOS DIGITAL" and "archive" look equally plausible in a list, and
// only one of them is what was just plugged in.
let card = dr_plat::Volume {
label: "EOS DIGITAL".into(),
path: "/run/media/duncan/EOS DIGITAL".into(),
removable: true,
has_dcim: true,
};
assert!(describe_volume(&card).starts_with("camera card · "));
let drive = dr_plat::Volume {
has_dcim: false,
..card.clone()
};
assert!(describe_volume(&drive).starts_with("removable volume · "));
let fixed = dr_plat::Volume {
removable: false,
has_dcim: false,
..card
};
assert!(describe_volume(&fixed).starts_with("volume · "));
}
}