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
+8
View File
@@ -479,6 +479,14 @@ fn choose_device_folder(window: &AppWindow, ctl: &Rc<AlbumsController>) {
}
render_sheet(&w, &ctl);
};
// Android has no filesystem dialogue; its folder picker hands back a
// tree the app is granted, which is what an export there writes into.
#[cfg(target_os = "android")]
{
let _ = (window, start);
crate::saf::pick_tree(chosen);
}
#[cfg(not(target_os = "android"))]
crate::folder_dialog::ask(
window,
"Folder for this album",
+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`].
+2
View File
@@ -68,6 +68,8 @@ mod recovery_ui;
mod refine;
mod remote;
mod remote_folders;
#[cfg(target_os = "android")]
mod saf;
pub mod repairs;
mod segmentation;
mod settings_store;
+206
View File
@@ -0,0 +1,206 @@
//! TRACES: FR-EXP-10 | FR-PLAT-AND-1
//! Android's Storage Access Framework, for an album's folder on the device.
//!
//! The folder is chosen in the system's own picker, which can make a new
//! folder too, and comes back as a tree URI with a persisted grant. Exports
//! are then written into it through `DocumentsContract` — a tree URI is not a
//! path, so nothing here touches the filesystem. The Java halves are
//! `FolderPicker.java` and `Saf.java` in the Android app; this is the JNI
//! bridge to them, in the jni 0.22 idiom `launch_ui::android_open_url` uses.
//!
//! The classes are loaded through the application's class loader rather than
//! `FindClass`: a worker thread attached from Rust sees only the system's
//! classes through `FindClass`, and an export writes from a worker.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use jni::objects::{JClass, JClassLoader, JObject, JString, JValue};
use jni::strings::JNIStr;
/// Run `body` with the application context ndk_context holds, on whatever
/// thread this is. It is a `Context`, not the activity — see
/// `FolderPicker.start` for what that changes.
fn call<T>(
what: &str,
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
) -> Result<T, String> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() || ctx.context().is_null() {
return Err(format!("{what}: no Android context"));
}
// SAFETY: the pointers come from ndk_context, which android-activity's
// glue fills in with the process's JavaVM and activity before any Rust
// runs; the activity outlives every call here.
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
let raw: jni::sys::jobject = ctx.context().cast();
vm.attach_current_thread(|env| -> jni::errors::Result<T> {
// SAFETY: valid for this frame, which is as long as it is used.
let activity = unsafe { JObject::from_raw(env, raw) };
let result = body(env, &activity);
// A Java exception left pending makes the next JNI call on this
// thread abort the process. The Java side logs its own failures, so
// describing it here is for the ones it did not expect.
if env.exception_check() {
env.exception_describe();
env.exception_clear();
}
result
})
.map_err(|e| format!("{what}: {e}"))
}
/// One of the app's own classes, through the application's class loader.
///
/// Asked of the context with `getClassLoader()`, not taken from the
/// context's class: that is a framework class (`android.app.NativeActivity`,
/// or the application context behind it) the boot loader defined, and the
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
/// which is what the first build on the tablet did.
fn class<'local>(
env: &mut jni::Env<'local>,
activity: &JObject,
name: &JNIStr,
) -> jni::errors::Result<JClass<'local>> {
let loader = env
.call_method(
activity,
jni::jni_str!("getClassLoader"),
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader)?;
jni::refs::LoaderContext::Loader(&loader).load_class(env, name, true)
}
/// A Java string result, or `None` for null.
fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
if value.is_null() {
return Ok(None);
}
let s = env.cast_local::<JString>(value)?;
Ok(Some(s.try_to_string(env)?))
}
/// Ask for a folder, and call `chosen` with its tree URI if one is picked.
///
/// Nothing is called on a cancel, matching `folder_dialog::ask`. The answer
/// is polled from the Slint timer on the UI thread: the picker is another
/// activity, and this one's event loop keeps running under it.
pub fn pick_tree(chosen: impl FnOnce(String) + 'static) {
let started = call("opening the folder picker", |env, activity| {
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.FolderPicker"))?;
env.call_static_method(
&cls,
jni::jni_str!("start"),
jni::jni_sig!("(Landroid/content/Context;)V"),
&[activity.into()],
)?;
Ok(())
});
if let Err(e) = started {
log::warn!("{e}");
return;
}
// The timer owns itself until the answer arrives; see
// `remote_folders::run` for why it is stopped and released separately.
let slot: Rc<RefCell<Option<slint::Timer>>> = Rc::new(RefCell::new(None));
let held = slot.clone();
let mut chosen = Some(chosen);
let since = Instant::now();
let timer = slint::Timer::default();
timer.start(slint::TimerMode::Repeated, Duration::from_millis(250), move || {
let answer = call("reading the folder picker", |env, activity| {
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.FolderPicker"))?;
let value = env
.call_static_method(
&cls,
jni::jni_str!("poll"),
jni::jni_sig!("()Ljava/lang/String;"),
&[],
)?
.l()?;
text(env, value)
});
let finished = match answer {
Ok(None) => since.elapsed() > Duration::from_secs(600),
Ok(Some(uri)) => {
if !uri.is_empty() {
if let Some(chosen) = chosen.take() {
chosen(uri);
}
}
true
}
Err(e) => {
log::warn!("{e}");
true
}
};
if finished {
if let Some(t) = held.borrow().as_ref() {
t.stop();
}
let held = held.clone();
slint::Timer::single_shot(Duration::ZERO, move || {
held.borrow_mut().take();
});
}
});
*slot.borrow_mut() = Some(timer);
}
/// Whether `name` is already in the folder. False when it cannot be told,
/// which lets the provider's own rename-on-collision be the backstop.
pub fn exists(tree: &str, name: &str) -> bool {
call("checking the album folder", |env, activity| {
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.Saf"))?;
let tree = env.new_string(tree)?;
let name = env.new_string(name)?;
env.call_static_method(
&cls,
jni::jni_str!("exists"),
jni::jni_sig!("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Z"),
&[activity.into(), (&tree).into(), (&name).into()],
)?
.z()
})
.unwrap_or_else(|e| {
log::warn!("{e}");
false
})
}
/// Write an export into the folder. Returns the name it has there, which the
/// provider may have changed on a collision.
pub fn write(tree: &str, name: &str, mime: &str, bytes: &[u8], replace: bool) -> Result<String, String> {
call("writing to the album folder", |env, activity| {
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.Saf"))?;
let jtree = env.new_string(tree)?;
let jname = env.new_string(name)?;
let jmime = env.new_string(mime)?;
let data = env.byte_array_from_slice(bytes)?;
let value = env
.call_static_method(
&cls,
jni::jni_str!("write"),
jni::jni_sig!(
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ)Ljava/lang/String;"
),
&[
activity.into(),
(&jtree).into(),
(&jname).into(),
(&jmime).into(),
(&data).into(),
JValue::Bool(replace),
],
)?
.l()?;
text(env, value)
})?
.ok_or_else(|| format!("the folder refused {name}; the log has the reason"))
}