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
@@ -158,6 +158,18 @@
android:theme="@style/ManualTheme" android:theme="@style/ManualTheme"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" /> android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
this device. NativeActivity's onActivityResult is not ours, so
this activity exists only to ask and hand the answer back (see
FolderPicker.java). Translucent and without a title so nothing
of it shows but the system chooser; not exported, and started by
class name from dr_ui::saf. -->
<activity
android:name="paris.tourolle.darkroom.FolderPicker"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs <!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG FileUriExposedException in *this* process — so an exported JPEG
@@ -0,0 +1,117 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
/**
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
*
* <h2>Why an activity of its own</h2>
*
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
* whose result callback is not ours to override. So this one exists only to
* ask: it starts the picker, takes the answer, and finishes — no layout, a
* translucent theme, nothing on screen but the system's own chooser, which has
* its own "New folder".
*
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
* than called back into native code: a callback would need a registered
* native method and a thread to deliver on, and a poll from the Slint timer
* that is already running is one static call.
*
* <h2>The grant</h2>
*
* <p>A tree URI is usable only while its permission is held, and a plain
* result grants it until the process dies. {@code takePersistableUriPermission}
* keeps it across restarts — an album's folder is chosen once and exported to
* for months.
*/
public final class FolderPicker extends Activity {
private static final String TAG = "DarkRoom";
private static final int REQUEST = 0x5AF;
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
private static volatile String answer = null;
/**
* Start asking. Clears any answer left from before.
*
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
* native code holds (ndk_context's handle) is the application context,
* and starting an activity from one that is not an activity needs
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
* shares the app's task affinity, so it still opens over the app and Back
* still returns to it.
*/
public static void start(Context from) {
answer = null;
Intent intent = new Intent(from, FolderPicker.class);
if (!(from instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
from.startActivity(intent);
}
/**
* The answer, once: a tree URI, "" if the user backed out, or null while
* the picker is still open. Reading it clears it, so a second poll after a
* cancel does not see the cancel again.
*/
public static String poll() {
String a = answer;
if (a != null) {
answer = null;
}
return a;
}
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
// Recreated after a rotation with the picker already up: asking again
// would stack a second chooser over the first.
if (state != null) {
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
try {
startActivityForResult(pick, REQUEST);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "no folder picker on this device", e);
answer = "";
finish();
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request != REQUEST) {
return;
}
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
if (tree == null) {
answer = "";
} else {
try {
getContentResolver().takePersistableUriPermission(tree,
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} catch (SecurityException e) {
// Still usable this session; said in the log so a folder that
// stops working after a restart has an explanation.
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
}
answer = tree.toString();
}
finish();
}
}
@@ -0,0 +1,114 @@
package paris.tourolle.darkroom;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.util.Log;
import java.io.IOException;
import java.io.OutputStream;
/**
* Writing an export into a folder the user granted through
* {@link FolderPicker} — the Storage Access Framework, which is the only way
* this app reaches a folder on the device (FR-PLAT-AND-1).
*
* <p>A tree URI is not a path: a child is found by listing the folder and
* matching its display name, and created through the provider, which may
* rename it on a collision. So the name that was actually written is handed
* back, and the album records that one.
*
* <p>Two static calls, strings and a byte array in, a string out, for the
* reason {@link Intents} gives: every call here would be a signature typed as
* a string on the Rust side, and the fewer of those the better.
*/
public final class Saf {
private static final String TAG = "DarkRoom";
private Saf() {
}
/** Whether {@code name} already exists in the folder. False on any error. */
public static boolean exists(Context context, String tree, String name) {
try {
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
} catch (RuntimeException e) {
Log.w(TAG, "checking " + name + " in " + tree, e);
return false;
}
}
/**
* Write {@code bytes} as {@code name} in the folder, replacing a file of
* that name when {@code replace} is set.
*
* @return the name the file has in the folder — the provider may have
* added " (1)" — or null on failure, with the reason in the log.
*/
public static String write(Context context, String tree, String name, String mime,
byte[] bytes, boolean replace) {
ContentResolver resolver = context.getContentResolver();
Uri treeUri = Uri.parse(tree);
try {
Uri target = replace ? find(resolver, treeUri, name) : null;
if (target == null) {
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
target = DocumentsContract.createDocument(resolver, folder, mime, name);
}
if (target == null) {
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
return null;
}
// "wt": truncate. A replacement shorter than what it replaces
// must not keep the old file's tail.
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
if (out == null) {
Log.w(TAG, "no stream for " + target);
return null;
}
out.write(bytes);
}
String written = displayName(resolver, target);
return written != null ? written : name;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "writing " + name + " to " + tree, e);
return null;
}
}
/** The document for {@code name} directly in the tree's folder, or null. */
private static Uri find(ContentResolver resolver, Uri tree, String name) {
String folderId = DocumentsContract.getTreeDocumentId(tree);
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
String[] columns = {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
};
try (Cursor c = resolver.query(children, columns, null, null, null)) {
if (c == null) {
return null;
}
while (c.moveToNext()) {
if (name.equals(c.getString(1))) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
}
}
}
return null;
}
private static String displayName(ContentResolver resolver, Uri document) {
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
try (Cursor c = resolver.query(document, columns, null, null, null)) {
if (c != null && c.moveToFirst()) {
return c.getString(0);
}
} catch (RuntimeException e) {
Log.w(TAG, "reading the name of " + document, e);
}
return null;
}
}
+8
View File
@@ -479,6 +479,14 @@ fn choose_device_folder(window: &AppWindow, ctl: &Rc<AlbumsController>) {
} }
render_sheet(&w, &ctl); 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( crate::folder_dialog::ask(
window, window,
"Folder for this album", "Folder for this album",
+43
View File
@@ -213,6 +213,21 @@ pub fn place(
if destination.trim().is_empty() { if destination.trim().is_empty() {
return Err("No export folder is set. Choose an album to export to.".into()); 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); let dir = PathBuf::from(destination);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(&encoded.name); 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. /// Write bytes and their destination record into the outbox.
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> { 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()))?; 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 dir = PathBuf::from(&settings.destination);
let taken = |name: &str| -> bool { let taken = |name: &str| -> bool {
match settings.target { 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(), dr_types::ExportTarget::Device => dir.join(name).exists(),
// A queued export cannot see the server, and may never be able to. // A queued export cannot see the server, and may never be able to.
// Names are kept apart in the outbox instead — see [`stage`]. // Names are kept apart in the outbox instead — see [`stage`].
+2
View File
@@ -68,6 +68,8 @@ mod recovery_ui;
mod refine; mod refine;
mod remote; mod remote;
mod remote_folders; mod remote_folders;
#[cfg(target_os = "android")]
mod saf;
pub mod repairs; pub mod repairs;
mod segmentation; mod segmentation;
mod settings_store; 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"))
}