//! 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( what: &str, body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result, ) -> Result { 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 { // 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> { let loader = env .call_method( activity, jni::jni_str!("getClassLoader"), jni::jni_sig!("()Ljava/lang/ClassLoader;"), &[], )? .l()?; let loader = env.cast_local::(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> { if value.is_null() { return Ok(None); } let s = env.cast_local::(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>> = 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 { 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")) }