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.
225 lines
8.3 KiB
Rust
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"))
|
|
}
|