Files
DarkRoom/ui/dr-ui/src/saf.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

229 lines
8.5 KiB
Rust

//! TRACES: FR-EXP-10
//! Android's Storage Access Framework, for an album's folder on the device.
//!
//! Export folders only. FR-PLAT-AND-1 asks for the *library* to be reached
//! through SAF, and it still is not — a library on the tablet is a server —
//! so this module does not claim it.
//!
//! 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.
pub(crate) 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.
pub(crate) 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.
pub(crate) 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"))
}