Stop the export folder forgetting itself, and let Android reach one

Three faults, compounding into an export that could not be made to work on a
tablet at all and a destination that appeared to reset on its own.

**Switching target destroyed the destination.** One field held both a
filesystem path and a remote folder, so changing the target had to clear it —
`/home/x/Exports` carried to the server would have offered to create a folder
called `home` at the library root. The consequence was that merely looking at
the other option threw away the destination already chosen, which reads,
correctly, as a setting that will not stick. There are two fields now. Each
target remembers where it was pointed and switching is free; `active_destination`
picks between them so no caller can reach for the wrong one.

**The library root read as "unset".** The picker opens at the root, so
confirming it where it opens stored an empty string — indistinguishable from
"ask each time" and looking exactly like the picker had done nothing. Empty
now means the library root for a server destination, which is a real folder
and the one the photographs are already in; it means "ask" only for a device
folder, where no path is worth assuming. The page labels it so.

**Android defaulted to a target it cannot use.** A device folder there means
the Storage Access Framework, which provides no filesystem path (ARCH §6.9)
and is not implemented — so the default target could never succeed however the
destination was filled in. The export button said "no export folder is set",
the settings page offered no way to choose one, and the only way out was to
guess that the other target was the working one. The device target is now
absent from `ExportTarget::available()` on Android and the default there is the
server, which needs no platform work at all. A settings file carrying an
unreachable target — copied from a desktop, say — is corrected on read rather
than left to fail at the last step.

Seven tests, each named for the fault it prevents returning. The compatibility
one matters most: a file written before `remote_destination` existed keeps its
device path and gains an empty remote one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:25:15 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 8ea92545df
3 changed files with 239 additions and 38 deletions
+2 -2
View File
@@ -351,7 +351,7 @@ fn export_now(
// since the server cannot be reached from here and may not be reachable
// at all — see `export::stage` for why two queued exports of one name
// both survive regardless.
let target_dir = std::path::PathBuf::from(&stored.export.destination);
let target_dir = std::path::PathBuf::from(stored.export.active_destination());
let taken = |name: &str| -> bool {
match stored.export.target {
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
@@ -373,7 +373,7 @@ fn export_now(
export::place(
&encoded,
stored.export.target,
&stored.export.destination,
stored.export.active_destination(),
&outbox,
)
}
+20 -23
View File
@@ -90,7 +90,7 @@ impl SettingsController {
/// survived only until the window closed would be the one setting that
/// behaved differently from all the others.
pub fn set_destination(&self, path: String) {
self.edit(|s| s.export.destination = path);
self.edit(|s| s.export.set_active_destination(path));
}
/// Report a failure onto the page's error line.
@@ -194,18 +194,19 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
window.set_settings_strip_location(s.export.strip_location);
window.set_settings_target_labels(labels(ExportTarget::ALL.iter().map(|t| t.label())));
window.set_settings_target_selected(index_of(&ExportTarget::ALL, &s.export.target));
window.set_settings_destination(s.export.destination.clone().into());
// `available()`, not `ALL`: Android cannot write to a device folder, and
// offering a target that fails at the last step is what made export look
// broken there rather than absent.
let targets = ExportTarget::available();
window.set_settings_target_labels(labels(targets.iter().map(|t| t.label())));
window.set_settings_target_selected(index_of(targets, &s.export.target));
window.set_settings_destination(s.export.active_destination().into());
// The field means different things either side of the choice, and a
// placeholder saying which is cheaper than a paragraph under it.
window.set_settings_destination_hint(
match s.export.target {
ExportTarget::Device => "Choose a folder…",
ExportTarget::Remote => "A folder under the library root, created if absent",
}
.into(),
);
// The placeholder names what an empty field *means*, which differs by
// target: on a filesystem it is a question, on the server it is the
// library root.
window.set_settings_destination_hint(s.export.destination_label().into());
// --- the remote folder picker --------------------------------------
{
@@ -518,7 +519,7 @@ where
let ctl = controller.clone();
window.on_settings_destination_changed(move |text| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.destination = text.to_string());
ctl.edit(|s| s.export.set_active_destination(text.to_string()));
render(&w, &ctl);
});
}
@@ -528,17 +529,13 @@ where
let ctl = controller.clone();
window.on_settings_target_changed(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(&t) = ExportTarget::ALL.get(i.max(0) as usize) {
// The destination is cleared with the switch. A path and a
// remote folder are not the same kind of string, and carrying
// `/home/x/Exports` across to the server would offer to create
// a folder called `home` at the library root.
ctl.edit(|s| {
if s.export.target != t {
s.export.target = t;
s.export.destination.clear();
}
});
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
// Nothing is cleared. Each target keeps its own destination
// (see `ExportSettings::remote_destination`), so switching to
// look at the other option no longer throws away the one
// already set — which is what made this setting appear not to
// stick.
ctl.edit(|s| s.export.target = t);
}
render(&w, &ctl);
});