Give an album a folder on the tablet, through Android's folder picker

Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
This commit is contained in:
2026-09-26 14:13:53 -04:00
parent 7cbcacc02e
commit 92d4b23bed
7 changed files with 502 additions and 0 deletions
+43
View File
@@ -213,6 +213,21 @@ pub fn place(
if destination.trim().is_empty() {
return Err("No export folder is set. Choose an album to export to.".into());
}
// TRACES: FR-EXP-10 | FR-PLAT-AND-1
// A SAF tree on Android: written through the provider, which may
// rename on a collision, so the name it reports is the one kept.
#[cfg(target_os = "android")]
if destination.starts_with("content://") {
let replace = collision_replaces(&encoded.name, destination);
let written = crate::saf::write(
destination,
&encoded.name,
mime_for(&encoded.name),
&encoded.bytes,
replace,
)?;
return Ok(Placed::Device(PathBuf::from(written)));
}
let dir = PathBuf::from(destination);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(&encoded.name);
@@ -231,6 +246,30 @@ pub fn place(
}
}
/// Whether writing `name` into a SAF folder should replace a file already
/// there. By the time `place` runs, the batch has already chosen the name
/// under the collision policy, so a name that is taken can only have been
/// chosen under Overwrite.
#[cfg(target_os = "android")]
fn collision_replaces(name: &str, tree: &str) -> bool {
crate::saf::exists(tree, name)
}
/// The MIME type a document provider is told, from the name the encoder gave.
#[cfg(target_os = "android")]
fn mime_for(name: &str) -> &'static str {
match Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("tif" | "tiff") => "image/tiff",
_ => "image/jpeg",
}
}
/// Write bytes and their destination record into the outbox.
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
@@ -983,6 +1022,10 @@ fn resolve_batch_name(
let dir = PathBuf::from(&settings.destination);
let taken = |name: &str| -> bool {
match settings.target {
#[cfg(target_os = "android")]
dr_types::ExportTarget::Device if settings.destination.starts_with("content://") => {
crate::saf::exists(&settings.destination, name)
}
dr_types::ExportTarget::Device => dir.join(name).exists(),
// A queued export cannot see the server, and may never be able to.
// Names are kept apart in the outbox instead — see [`stage`].