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