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:
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user