Take the photograph another app hands over, and hand an export back

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>
This commit is contained in:
2026-08-29 23:32:48 +02:00
co-authored by Claude Opus 5
parent 24bf5be574
commit 09dddde594
7 changed files with 1075 additions and 3 deletions
+192
View File
@@ -0,0 +1,192 @@
//! 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();
}
}
+186 -2
View File
@@ -8,6 +8,17 @@
//! browsing list and the library grid is the only way in.
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
//! discards.
//!
//! The first of those has one exception, and it is the launch `Intent`: a
//! gallery, a file manager or the share sheet can name images to open, and
//! those arrive as URIs on an `Intent` rather than as words on a command line.
//! [`intents`] turns them into paths, and from there they are the same list
//! the desktop builds from `argv` (FR-PLAT-AND-6).
// The whole module is JNI against classes that exist only in the APK, so it
// is gated with everything else that cannot compile off-device.
#[cfg(target_os = "android")]
mod intents;
// `slint::android` exists only when compiling for Android, so the whole entry
// point is gated on the target rather than on a feature. Without this the
@@ -51,6 +62,12 @@ fn android_main(app: slint::android::AndroidApp) {
// After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app);
// Before `init_with_event_listener`, which takes `app` by value and is the
// last moment anything can ask the activity a question. Not an ordering
// preference — after that line there is no `app` left to read the Intent
// through.
let opened_with = intents::launch_images(&app);
// TRACES: FR-PLAT-AND-5
// The listener is the whole reason this is not the one-line
// `slint::android::init(app)`. Slint owns the event loop on Android, so
@@ -96,11 +113,15 @@ fn android_main(app: slint::android::AndroidApp) {
return;
}
// Empty rather than the desktop's argv: see the module note above.
// The launch Intent's images, where there were any, standing in for the
// desktop's argv — `launch::startup_action` treats a non-empty list as
// "the user asked for these specifically", which is exactly what a share
// or a tap in a gallery is. Empty for an ordinary launch, and the library
// opens as before.
//
// Returning from `android_main` ends the process, so a failure here is
// logged rather than propagated — there is no shell to show `Err` to.
if let Err(e) = dr_ui::run(Vec::new()) {
if let Err(e) = dr_ui::run(opened_with) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
@@ -179,3 +200,166 @@ fn install_bundled_face_models(app: &slint::android::AndroidApp) {
}
}
}
/// TRACES: FR-PLAT-AND-6
/// The declarations that make this app a receiver, held to on the host.
///
/// Everything FR-PLAT-AND-6 does on a device is unreachable from `cargo test`:
/// there is no `Intent` off-device and no `ContentProvider` to instantiate. But
/// the requirement is not only behaviour — half of it is *declaration*, and a
/// declaration can be wrong in ways that compile perfectly and fail silently.
/// An intent filter that is deleted takes the app out of every gallery's "open
/// with" menu with nothing to notice; an authority that stops matching the
/// class it names raises a `SecurityException` in whichever other app opened
/// the share sheet, which is the last place anybody would look for it.
///
/// The manifest is read by aapt2 and the Java by javac, so a Rust build sees
/// neither. `include_str!` is what puts them where a test can reach them, and
/// this is the only place in the workspace that does.
#[cfg(test)]
mod tests {
/// The manifest with its comments removed and its whitespace flattened, so
/// a match is about the declaration and not about how it is indented.
fn manifest() -> String {
let xml = include_str!("../android/AndroidManifest.xml");
let mut out = String::with_capacity(xml.len());
let mut rest = xml;
// Comments first, and not by regex over the whole file: several of them
// quote the very attribute names the assertions below look for, so a
// test that read them would pass on the strength of the prose
// explaining an entry that had been deleted.
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The body of each `<intent-filter>`, so an action and a MIME type are
/// checked to be in the *same* filter. Two filters, one naming the action
/// and one naming the type, register for neither.
fn intent_filters(manifest: &str) -> Vec<&str> {
manifest
.split("<intent-filter>")
.skip(1)
.filter_map(|filter| filter.split("</intent-filter>").next())
.collect()
}
/// The single `<provider>` element, attributes and all.
fn provider(manifest: &str) -> String {
let start = manifest
.find("<provider")
.expect("no <provider> in the manifest");
let rest = &manifest[start..];
let end = rest.find("/>").expect("unterminated <provider> element");
rest[..end + 2].to_string()
}
fn attribute(element: &str, name: &str) -> Option<String> {
let key = format!("{name}=\"");
let start = element.find(&key)? + key.len();
let value = element[start..].split('"').next()?;
Some(value.to_string())
}
#[test]
fn a_gallery_can_open_a_photograph_in_this_app() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
filter.contains("android.intent.action.VIEW")
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no VIEW filter for image/*: nothing will offer DarkRoom for a photograph"
);
}
#[test]
fn the_share_sheet_can_send_one_image_or_several() {
let manifest = manifest();
let registered = intent_filters(&manifest).iter().any(|filter| {
// The closing quote matters: SEND is a prefix of SEND_MULTIPLE, so
// a bare substring test passes on a filter that declares only the
// second and would not be offered for a single photograph.
filter.contains(r#"android.intent.action.SEND""#)
&& filter.contains(r#"android.intent.action.SEND_MULTIPLE""#)
&& filter.contains("android.intent.category.DEFAULT")
&& filter.contains(r#"android:mimeType="image/*""#)
});
assert!(
registered,
"no SEND/SEND_MULTIPLE filter for image/*, so the share sheet will not list DarkRoom"
);
}
#[test]
fn one_activity_ever_so_a_second_launch_cannot_start_a_second_one() {
// Not style. Another app can now launch this activity while it is
// already running, and the default launch mode answers that by
// creating a second NativeActivity in this process — a second
// android_main, a second Slint backend, a second wgpu device.
assert!(
manifest().contains(r#"android:launchMode="singleTask""#),
"the activity must be singleTask; see the manifest comment"
);
}
#[test]
fn the_provider_authority_is_the_one_the_class_answers_to() {
let manifest = manifest();
let element = provider(&manifest);
let declared =
attribute(&element, "android:authorities").expect("the provider declares no authority");
let java = include_str!("../android/java/paris/tourolle/darkroom/ExportProvider.java");
let constant = java
.split("AUTHORITY = \"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("ExportProvider declares no AUTHORITY constant");
assert_eq!(
declared, constant,
"the manifest and ExportProvider disagree about the authority; \
a share would fail as a SecurityException inside the receiving app"
);
let class = attribute(&element, "android:name").expect("the provider declares no class");
let (package, _) = class
.rsplit_once('.')
.expect("the provider class is unqualified");
assert!(
java.contains(&format!("package {package};")),
"the manifest names {class}, which is not the class in ExportProvider.java"
);
}
#[test]
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
let manifest = manifest();
let element = provider(&manifest);
// The two halves are not redundant. Without the grant, every share
// target fails; exported, every app on the device could read this
// app's private directory.
assert_eq!(
attribute(&element, "android:exported").as_deref(),
Some("false"),
"an exported provider would serve the app's private directory to anything installed"
);
assert_eq!(
attribute(&element, "android:grantUriPermissions").as_deref(),
Some("true"),
"without URI grants the share sheet opens and every target fails to read the file"
);
}
}