FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for image view and share intents, and share exported results out through a FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter, so nothing on the device ever offered DarkRoom for a photograph, and there was no route out at all — Android has refused file:// URIs between apps since API 24, and a content:// URI needs a provider to be behind it. Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the launch Intent before it gives `app` away to Slint, and what comes back is passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action` already treats a non-empty list as "the user asked for these specifically", which is what a share is. The URIs are copied into the cache before the viewer opens, and that cost is real: a shared raw file is written once, in full, on the startup path. A content:// URI is a handle into another app's provider, not a path, and the decoders take paths; the alternative is teaching the whole read path about URIs, which is FR-PLAT-AND-1's SAF connector and is not built. Outbound. ExportProvider serves one directory — getFilesDir(), which is the same path `internal_data_path` gives the Rust side — and refuses everything else by canonicalising the request and checking it is inside that root, so `../` and a planted symlink fail the same test. Not AndroidX's FileProvider, because AndroidX is a Maven artefact and this build has no resolver; what it does is a hundred lines and they are here. The share half has no caller. The provider, the URI grant and the chooser are all in place, but the control that would invoke them belongs in `ui/dr-ui`, and wiring it needs an `AndroidApp` the interface can reach. It is documented as unwired and deliberately not tagged as covering the requirement. `launchMode="singleTask"` comes with the filters and is not decoration: another app can now launch this activity while it is running, and the default mode answers that by creating a second NativeActivity in the same process — a second android_main, a second Slint backend, a second wgpu device. The cost of the fix is stated in the manifest: a share arriving while DarkRoom is already open brings it forward without opening the image, because onNewIntent has no route through android-activity's event stream. The Java is Java because Android constructs it: a ContentProvider is instantiated by the system from its manifest entry, and getIntent() exists only on an activity object. Both directions live there rather than in JNI so that what crosses the boundary is two method signatures instead of forty, each of which is a string checked at run time and nowhere else. What a test can hold: the declarations. Nothing about an Intent or a ContentProvider is reachable from `cargo test`, but an intent filter that is deleted takes the app out of every "open with" menu silently, and an authority that stops matching its class raises a SecurityException inside somebody else's app. The tests in lib.rs read the manifest and ExportProvider.java through `include_str!` and hold both to that, on the host, which is the only place in the workspace that looks at either file from Rust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
8.2 KiB
Rust
193 lines
8.2 KiB
Rust
//! What the app was launched with, and handing a finished export back out.
|
|
//!
|
|
//! FR-PLAT-AND-6's Rust side, which is deliberately the thin side. Both
|
|
//! directions are implemented in `android/java/paris/tourolle/darkroom/` and
|
|
//! everything here is the two calls that reach them; `Intents.java` carries the
|
|
//! reasoning for the split. The short version is that a JNI method signature is
|
|
//! a string Java resolves at run time and nothing checks at build time, so
|
|
//! forty of them is forty ways for a rename to become a `NoSuchMethodError` on
|
|
//! somebody's tablet. Two is two.
|
|
//!
|
|
//! # Nothing here fails loudly
|
|
//!
|
|
//! A class the loader cannot see, a pending Java exception, a shared URI whose
|
|
//! grant died with the task that received it: each ends as a log line and an
|
|
//! empty result. This runs on the way to [`dr_ui::run`], before a window
|
|
//! exists, and the alternative to opening with an empty browsing list is not
|
|
//! opening at all.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use jni::errors::Result as JniResult;
|
|
use jni::objects::{JClass, JObject, JObjectArray, JString, JValue};
|
|
use jni::{JNIEnv, JavaVM};
|
|
|
|
/// The class both directions live in, named the way `loadClass` wants it —
|
|
/// dots, not slashes. `find_class` takes the other form, and this code calls
|
|
/// neither by accident; see [`load_class`].
|
|
const INTENTS: &str = "paris.tourolle.darkroom.Intents";
|
|
|
|
/// The images this launch was asked to open, already local and readable.
|
|
///
|
|
/// Empty for an ordinary launch from the launcher, which is the common case
|
|
/// and not a failure. What comes back is passed to `dr_ui::run` exactly as
|
|
/// command-line paths are on the desktop, so a shared photograph becomes the
|
|
/// browsing list and `startup_action` shows it rather than the launch screen.
|
|
pub fn launch_images(app: &slint::android::AndroidApp) -> Vec<PathBuf> {
|
|
with_activity(app, "reading the launch intent", |env, activity| {
|
|
let class = load_class(env, activity, INTENTS)?;
|
|
let returned = env
|
|
.call_static_method(
|
|
&class,
|
|
"receive",
|
|
"(Landroid/app/Activity;)[Ljava/lang/String;",
|
|
&[JValue::Object(activity)],
|
|
)?
|
|
.l()?;
|
|
|
|
let array = JObjectArray::from(returned);
|
|
let count = env.get_array_length(&array)?;
|
|
let mut paths = Vec::with_capacity(count as usize);
|
|
for i in 0..count {
|
|
let element = env.get_object_array_element(&array, i)?;
|
|
let text: String = env.get_string(&JString::from(element))?.into();
|
|
paths.push(PathBuf::from(text));
|
|
}
|
|
Ok(paths)
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Offer a file this app produced to whatever else is installed.
|
|
///
|
|
/// `false` means the sheet did not open — the file is not under the directory
|
|
/// [`ExportProvider`] serves, or nothing installed accepts the type. Both are
|
|
/// answers a caller has to be able to give the user, because a share control
|
|
/// that silently does nothing is indistinguishable from one that failed.
|
|
///
|
|
/// **This half has no caller yet, and that is the honest state of it.** The
|
|
/// provider, the URI grant and the chooser are all here and are what
|
|
/// FR-PLAT-AND-6 asks for; what is missing is a share control in the interface,
|
|
/// which lives in `ui/dr-ui` and needs one thing this signature shows: an
|
|
/// `AndroidApp` to call through. Wiring it means keeping a clone of the app —
|
|
/// it is `Clone` and cheap — somewhere `ui/` can reach, which is a change to
|
|
/// how the platform entry point talks to the interface rather than a change
|
|
/// here. Until that exists this function is reachable and untested, and it is
|
|
/// deliberately not tagged as covering the requirement.
|
|
///
|
|
/// `mime` decides which applications the chooser offers; the empty string
|
|
/// falls back to `image/*` on the Java side.
|
|
pub fn share(app: &slint::android::AndroidApp, file: &Path, mime: &str) -> bool {
|
|
with_activity(app, "opening the share sheet", |env, activity| {
|
|
let class = load_class(env, activity, INTENTS)?;
|
|
let path = env.new_string(file.to_string_lossy().as_ref())?;
|
|
let mime = env.new_string(mime)?;
|
|
env.call_static_method(
|
|
&class,
|
|
"share",
|
|
"(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
|
|
&[
|
|
JValue::Object(activity),
|
|
JValue::Object(&path),
|
|
JValue::Object(&mime),
|
|
],
|
|
)?
|
|
.z()
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Attach to the JVM, borrow the activity, and run `body` against both.
|
|
///
|
|
/// Shared by the two entry points because the three steps before the
|
|
/// interesting one are identical and each has its own way of failing. `body`
|
|
/// returning `Err` is reported here, once, in the one place that can also clear
|
|
/// a pending Java exception — see [`report`].
|
|
fn with_activity<T>(
|
|
app: &slint::android::AndroidApp,
|
|
doing: &str,
|
|
body: impl FnOnce(&mut JNIEnv, &JObject) -> JniResult<T>,
|
|
) -> Option<T> {
|
|
let vm = match unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } {
|
|
Ok(vm) => vm,
|
|
Err(e) => {
|
|
log::error!("no JVM handle, so {doing} is skipped: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
// Cheap when the thread is already attached, which it is: the glue
|
|
// attached it before it called `android_main`. The guard exists for the
|
|
// case where it is not, and costs a lookup where it is.
|
|
let mut env = match vm.attach_current_thread() {
|
|
Ok(env) => env,
|
|
Err(e) => {
|
|
log::error!("cannot attach to the JVM, so {doing} is skipped: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// SAFETY: `activity_as_ptr` documents this as an unowned JNI *global*
|
|
// reference to the Activity, valid for as long as the `AndroidApp` it came
|
|
// from. `JObject` in jni 0.21 is a plain wrapper with no `Drop`, so
|
|
// borrowing it here cannot delete a reference this code does not own — the
|
|
// one way to get this wrong is `AutoLocal` or a `GlobalRef`, both of which
|
|
// would free it out from under android-activity.
|
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
|
|
|
match body(&mut env, &activity) {
|
|
Ok(value) => Some(value),
|
|
Err(e) => {
|
|
report(&mut env, doing, &e);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Look an app class up through the *activity's* class loader.
|
|
///
|
|
/// `find_class` is the obvious call and the wrong one. JNI resolves a class
|
|
/// against the loader belonging to the Java frame beneath the call, and on this
|
|
/// thread there is no such frame: `android_main` runs on a thread the native
|
|
/// glue created and attached itself, so the loader in scope is the system one.
|
|
/// It knows every class in the platform and nothing at all from this APK, and
|
|
/// says so as a `ClassNotFoundException` naming a class that is plainly in the
|
|
/// dex — which reads as a broken build rather than as the wrong loader.
|
|
///
|
|
/// The activity is a Java object, so its loader is the app's.
|
|
fn load_class<'local>(
|
|
env: &mut JNIEnv<'local>,
|
|
activity: &JObject,
|
|
name: &str,
|
|
) -> JniResult<JClass<'local>> {
|
|
let loader = env
|
|
.call_method(activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])?
|
|
.l()?;
|
|
let name = env.new_string(name)?;
|
|
let class = env
|
|
.call_method(
|
|
&loader,
|
|
"loadClass",
|
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
|
&[JValue::Object(&name)],
|
|
)?
|
|
.l()?;
|
|
Ok(JClass::from(class))
|
|
}
|
|
|
|
/// Log a JNI failure, and clear the exception behind it if there is one.
|
|
///
|
|
/// The clearing is not tidiness. A Java exception raised through JNI stays
|
|
/// *pending* on the thread, and the next JNI call made while one is pending
|
|
/// aborts the process — so a swallowed exception here would come back as a
|
|
/// crash somewhere unrelated, most likely inside Slint. `exception_describe`
|
|
/// first, because the trace it prints to logcat is the only place the Java
|
|
/// class and line survive; `jni::errors::Error::JavaException` on its own says
|
|
/// neither.
|
|
fn report(env: &mut JNIEnv, doing: &str, e: &jni::errors::Error) {
|
|
log::error!("{doing} failed: {e}");
|
|
if let Ok(true) = env.exception_check() {
|
|
let _ = env.exception_describe();
|
|
let _ = env.exception_clear();
|
|
}
|
|
}
|