Files
DarkRoom/ui/dr-ui/src/saf.rs
T
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00

225 lines
8.3 KiB
Rust

//! 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"))
}