Files
DarkRoom/apps/darkroom-android/src/lib.rs
T
dtourolle a9271c4850 Make Best one network, and retire Medium and the mixture
Best was a mixture of two experts and a gate, 110 GMAC a megapixel;
Medium a single network at 48 that was softer on real edges. fb-combo
(darkroom-denoise, 20 000 steps from fb-edges2, taught by the mixture
with a quarter of its crops from the edge-rich parts of the frames) is
Medium's shape and holds the mixture's edges on real photographs:
edge PSNR within 0.04-0.06 dB at ISO 1600/6400/25600, more sharpness
kept at all three, the chart's edge 0.89 photosites wide against 0.82.
It is 0.27 dB short on smooth areas at ISO 25600. It becomes Best, and
the methods are Bilinear, Fast and Best.

Saved edits keep their numbers: 2, which was Medium, is now Best, and
3, which was Best, is past the end and reads as the default, Best.
The network ships as mosaic-hq, a new name: the result cache keys a
model by name and size, and this one is byte for byte the old Medium's
size. Its tablet form (A16W16) lost 0.00 dB in simulated QDQ at every
ISO and at most 0.09 dB across the noise bracket.
2026-10-07 06:58:24 -04:00

678 lines
32 KiB
Rust

//! DarkRoom Android entry point.
//!
//! The counterpart to `darkroom-desktop`'s `main`, with two differences that
//! come from the platform rather than from choice:
//!
//! * There are no command-line paths. Android's SAF hands out document URIs,
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
//! browsing list and the library grid is the only way in.
//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr,
//! which Android discards; logcat replaces it, and a rotating file beside it
//! replaces the thing logcat cannot be — a record that outlives the session
//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]).
//!
//! 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
// crate is still a workspace member on the host, and `cargo test --workspace`
// fails to compile it — a build break that only ever appears off-device.
#[cfg(target_os = "android")]
/// TRACES: M-13 | M-14
/// Android application entry point, called by android-activity's glue.
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
// **The first statement in the process, and it has to be.** Everything
// between here and `install` returning runs with no logger installed at
// all: asking the activity for its external directory, `create_dir_all`
// and an `open` on a FUSE-backed volume the system may still be mounting.
// A failure or a stall in any of it is invisible on every surface there
// is — no file yet, and nothing in logcat either — which is precisely the
// kind of launch logcat exists to debug.
//
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
// than replaced: `init_once` installs itself as the global logger and
// there is only one of those. Everything that reached logcat before the
// file existed still reaches it, at the same level and under the same tag;
// the file is strictly additional.
let console = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
// Handed to the logger directly, and **not** written as `log::info!`,
// which here would compile and emit nothing: the facade's maximum level is
// `Off` until `diagnostics::install` sets it, and the macro tests that
// before it reaches any logger at all. This call skips the facade and
// reaches `__android_log_write` with nothing in between.
//
// That independence is the second reason for it. When the log is silent,
// this line is what says which half is at fault: present here and absent
// below means the `log` wiring, absent in both means liblog is not
// delivering this process's records — a question about the device, which
// no amount of reading this file can answer.
log::Log::log(
&console,
&log::Record::builder()
.level(log::Level::Info)
.target(module_path!())
.module_path(Some(module_path!()))
.args(format_args!(
"DarkRoom v{} starting; logcat only until the log file opens",
env!("CARGO_PKG_VERSION")
))
.build(),
);
// Before the file logger, because it needs somewhere to write.
//
// **The external directory, not the internal one, and the difference is
// the entire point of the file.** Both are app-private and both survive
// backgrounding — the volatile one is the *cache* directory, which is not
// in play here. What separates them is retrieval:
// `/data/data/<pkg>/files` needs `run-as` against a debuggable build or
// root to read, and `/sdcard/Android/data/<pkg>/files` is a plain
// `adb pull` from any build, needing no permission since API 19. A log
// nobody can get off the device does not do the job NFR-OPS-1 describes.
//
// The consequence is that anyone holding the tablet can read it, which is
// why `dr_plat::diagnostics` redacts at the sink and why configuration —
// the account list, and the credential reference beside it — stays on
// `internal_data_path` below rather than moving here (NFR-SEC-2).
let external = app.external_data_path();
if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) {
dr_plat::set_state_dir(dir);
}
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
// Panics go to stderr, and Android discards stderr. Without this hook a
// worker thread that panics is invisible: the process survives, the
// channel it was writing to closes, and the UI reports only that
// something "failed unexpectedly" with no way to find out what.
//
// This used to be one `log::error!` of the raw panic, which had two
// problems: logcat is a ring buffer that is gone by the time a user
// reports anything, and the raw message can carry a document URI naming
// their library or a credential a library interpolated into an error
// (NFR-SEC-2). `dr_plat::crash` writes a redacted record to disk and logs
// the redacted form. Nothing uploads it.
//
// Before `set_state_dir` on purpose: the hook resolves the directory when
// it fires, so installing it first covers the startup below rather than
// leaving it uncovered.
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Said in logcat as well as in the file, because the first thing anybody
// asked for a log needs is where it is — and on a device that is a path
// nobody can guess and a command nobody remembers.
match &logging {
dr_plat::Installed::ToFile(path) => {
log::info!("logging to {}", path.display());
if external.is_none() {
log::warn!(
"no external storage; the log is app-private and needs \
`adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \
on a debuggable build"
);
}
}
dr_plat::Installed::ConsoleOnly(why) => {
log::warn!("no log file this session, only logcat: {why}");
}
}
// Before anything opens a store: Android has no $HOME and no XDG
// directories, so the default guess resolves to a path the app cannot
// write. Nothing failed loudly — the session list went to a doomed path, so
// the account survived only as long as the process and backgrounding the app
// lost the sign-in. `internal_data_path` is the app's private directory
// (ARCH §6.9).
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
// Crash records go beside the account data rather than under it:
// both are app-private and neither is a cache, which is the whole
// distinction that matters here (see `dr_plat::crash::state_dir`).
dr_plat::crash::set_state_dir(dir.join("state"));
dr_sync::account::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
// After the data dir, because it writes beside the catalog. **Not** before
// the first frame any more — it starts a worker and returns; see the
// function for what it used to cost the launch.
install_bundled_models(app.clone());
// 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
// the platform's lifecycle and memory events reach the application only if
// it asks for them here — and it must ask *before* the loop starts, which
// is why this sits between the data directory and `dr_ui::run`.
//
// The listener runs inside `poll_events`, on the same thread the event
// loop and every interface cache live on, which is what lets
// `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a
// cross-thread channel (see its module documentation).
//
// # Why two events and not eight
//
// FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade
// how badly the system wants the memory back. Those levels do not exist
// here: `ComponentCallbacks2` is a Java interface implemented by an
// `Activity` or `Application`, and this app has neither — it is a bare
// `NativeActivity`, whose native callback table offers only the ungraded
// `onLowMemory`. android-activity surfaces exactly that as `LowMemory`.
// Reading the grades would mean shipping a Java subclass to forward them,
// which is a distribution-manifest change and not this one.
//
// `Stop` recovers the one grade that matters most anyway, and for free.
// It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN`
// in all but name — and it is the cheapest possible time to give memory
// back, because nothing that is freed has to be drawn again before anyone
// sees it. `Pause` deliberately does not qualify: a permission dialog or
// the share sheet pauses an activity that is still on screen behind it,
// and throwing away its render pipeline would make every such interruption
// cost a full re-render.
use slint::android::android_activity::{MainEvent, PollEvent};
if let Err(e) = slint::android::init_with_event_listener(app, |event| match event {
PollEvent::Main(MainEvent::LowMemory) => {
dr_ui::memory::relieve(dr_ui::memory::Level::Critical);
}
PollEvent::Main(MainEvent::Stop) => {
dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden);
}
_ => {}
}) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
// 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(opened_with) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
/// Unpack the models the APK carries, if it carries any.
///
/// # Why Android needs this and no other platform does
///
/// A desktop build reads its models from a path — the account's directory, the
/// shared one, or `$XDG_DATA_DIRS` where a package put them. **Android has no
/// such path.** `internal_data_path` is app-private, `run-as` needs a
/// debuggable build, and an asset inside a package is not a path anything can
/// open (ARCH §6.9), so a phone had no way to reach a model at all.
///
/// So the APK carries them in `assets/models/` and this copies them out, once,
/// into the same shared directory a desktop install uses. After that every
/// lookup in `dr_ui::library` finds them exactly where it finds a desktop
/// user's.
///
/// # The two sets are not the same kind of thing
///
/// **Face weights are absent from the repository by design.** The InsightFace
/// grant is research-only and incompatible with this project's licence
/// (docs/dev/faces.md §2), so a desktop user fetches them, runs
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
/// that carries none is the ordinary case and face indexing simply stays off.
///
/// **The scene model is committed** (AGPL, compatible — `models/LICENCE.md`),
/// so a build carrying none means a checkout without `git lfs pull` rather than
/// a deliberate omission. It is still not an error here: the scene tab reports
/// itself unavailable the same way face indexing does, because a photo editor
/// that refuses to start over a missing grading feature is worse than one that
/// starts without it.
///
/// # Why it is not `include_bytes!` like the instance model
///
/// Size. The instance model is 11 MB and compiled in; the scene model is 24 MB
/// on top of that, and a 35 MB constant in the binary is paid by every install
/// whether or not the tab is opened. Assets are also *stored* rather than
/// deflated in the APK (see `assemble-apk.sh`), so unpacking is a copy rather
/// than an inflate.
///
/// # Why it returns before it has done anything
///
/// **`android_main` runs with the input channel unserviced.** Nothing drains
/// it until Slint reaches `poll_events`, and Slint does not reach `poll_events`
/// until `dr_ui::run` calls `window.run()`, which is the last line of it. So
/// every millisecond spent between the top of `android_main` and that line is a
/// millisecond in which Android's input dispatcher gets no answer, and five
/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't
/// responding" over a window that has never painted, and offers to kill it.
///
/// This copied **41 MB** on the first launch after an install: 24.9 MB of scene
/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the
/// APK and written to `/data`. v0.10.0 added the scene model, which was 60% of
/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor
/// faults in its report are what 41 MB of freshly touched pages looks like.
/// The two further detectors the settings page offers since have made it
/// 61 MB, which is the same argument with a larger number.
///
/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and
/// this function returns as soon as the thread is running. Nothing on the
/// launch path waits for it, and no other startup step needs its result.
///
/// # The window in which a model looks absent, and why that is honest enough
///
/// Until the copy finishes, `library::face_models` and `library::scene_model`
/// answer `is_file()` about files that are not written yet, so both report
/// their feature unavailable — the same answer they give a build carrying no
/// weights at all, which is the ordinary case this whole path was written
/// around. It is briefly pessimistic rather than wrong, it lasts about as long
/// as it takes to read one screenful of the grid, and the temporary name
/// [`unpack_bundled_models`] writes under is what stops it being worse than
/// pessimistic: a lookup never sees a half-written file, only an absent one.
#[cfg(target_os = "android")]
fn install_bundled_models(app: slint::android::AndroidApp) {
// Detached rather than joined: there is no later moment on the launch path
// that wants the answer, and a handle nobody joins is a handle nobody can
// forget to. `AndroidApp` is documented `Send` and `Sync` and is an `Arc`
// internally, so the clone costs a refcount; `asset_manager` is asked for
// on the worker because `AAssetManager` is thread-safe by contract and
// reading the pointer takes only the app's read lock, which `poll_events`
// also only ever holds shared.
dr_ui::executors::spawn(dr_ui::executors::Executor::Io, "models", move || {
unpack_bundled_models(&app)
});
}
/// The copy itself, on the worker [`install_bundled_models`] starts.
#[cfg(target_os = "android")]
fn unpack_bundled_models(app: &slint::android::AndroidApp) {
use std::io::Read;
let started = std::time::Instant::now();
// The face names are the **shape-fixed** exports, matching what
// `library::face_models` looks for: tract cannot parse either InsightFace
// graph with its dynamic input dimension, so what ships here has already
// been through `tools/fix-face-model-shapes.sh`.
//
// The scene entries are three files rather than one because the graph alone
// decodes to 150 anonymous channels — `library::scene_model` wants the
// vocabulary and the category descriptor beside it, and requires all three
// before it reports the tab available.
//
// Three detectors, because which one runs is a setting
// (`FaceDetector`, docs/dev/faces.md §12.3) and a tablet has no other way to
// obtain the one it was not shipped with. Twenty megabytes of APK for
// the choice; the embedder is the same for all three.
//
// Then the three eye-state models (docs/dev/faces.md §17): landmarks, open
// or closed, sunglasses. The app indexes without them; with them the
// eyes-open filter has something to read, and a tablet has no other way
// to get them either.
//
// The quantised siblings — `.a16w8.onnx`, `.a16w16.onnx` — are what the
// Hexagon runs (docs/dev/inference.md §1.5), each in the narrowest form
// that held that model's accuracy on the tablet; the engine loads the
// sibling when the probe chose that rung and ignores it otherwise. The
// segmenter's and XFeat's forms are compiled into the binary instead,
// beside their f32 graphs.
const BUNDLED: [(&std::ffi::CStr, &str); 21] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(
c"models/scrfd_500m_640.a16w8.onnx",
"scrfd_500m_640.a16w8.onnx",
),
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
(
c"models/scrfd_2.5g_640.a16w8.onnx",
"scrfd_2.5g_640.a16w8.onnx",
),
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
(
c"models/scrfd_10g_640.a16w8.onnx",
"scrfd_10g_640.a16w8.onnx",
),
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
(c"models/2d106det_b1.onnx", "2d106det_b1.onnx"),
(c"models/2d106det_b1.a16w8.onnx", "2d106det_b1.a16w8.onnx"),
(c"models/ocec_s_b1.onnx", "ocec_s_b1.onnx"),
(c"models/sgc_l_48_b1.onnx", "sgc_l_48_b1.onnx"),
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
(
c"models/yolo26s-sem-ade20k.a16w16.onnx",
"yolo26s-sem-ade20k.a16w16.onnx",
),
(
c"models/yolo26s-sem-ade20k.classes.json",
"yolo26s-sem-ade20k.classes.json",
),
(c"models/categories.txt", "categories.txt"),
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
(c"models/migan-512.onnx", "migan-512.onnx"),
(c"models/migan-512.a16w16.onnx", "migan-512.a16w16.onnx"),
// The learned demosaic and denoise, one network per method
// (FR-DEV-3g), each with the 16-bit form the Hexagon runs.
(c"models/mosaic-fast-1408.onnx", "mosaic-fast-1408.onnx"),
(
c"models/mosaic-fast-1408.a16w16.onnx",
"mosaic-fast-1408.a16w16.onnx",
),
(c"models/mosaic-hq-1408.onnx", "mosaic-hq-1408.onnx"),
(
c"models/mosaic-hq-1408.a16w16.onnx",
"mosaic-hq-1408.a16w16.onnx",
),
];
let dir = dr_ui::shared_face_models_dir();
let assets = app.asset_manager();
let mut copied = 0u64;
for (asset_path, name) in BUNDLED {
let dest = dir.join(name);
// Already unpacked. Not re-read on every launch: this is 73 MB of
// copying across the ten entries, and the file does not change without
// the APK changing, at which point the install wiped it anyway. It
// matters more now than it did — a launch that skips every entry here
// costs nothing at all, which is what makes the second launch after an
// install cheap even though the first one is not.
if dest.is_file() {
continue;
}
let Some(mut asset) = assets.open(asset_path) else {
log::info!("no bundled {name} in this APK; the feature needing it stays off");
continue;
};
let mut bytes = Vec::new();
if let Err(e) = asset.read_to_end(&mut bytes) {
log::error!("bundled {name} could not be read: {e}");
continue;
}
if let Err(e) = std::fs::create_dir_all(&dir) {
log::error!("cannot create {}: {e}", dir.display());
return;
}
// Written under a temporary name and renamed, because
// `library::face_models` and `library::scene_model` both decide a
// feature is available on `is_file()` alone. A truncated write — the process backgrounded and
// killed mid-copy — would otherwise leave a file that passes that test
// and fails inside tract, reported to the user as a broken model rather
// than a missing one.
let part = dir.join(format!("{name}.part"));
match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) {
Ok(()) => {
copied += bytes.len() as u64;
log::info!("installed bundled {name} ({} bytes)", bytes.len());
}
Err(e) => {
log::error!("cannot install {name}: {e}");
let _ = std::fs::remove_file(&part);
}
}
}
// The figure this whole function is about. Said even when it is zero, so a
// launch that ANRs anyway can be told apart from one that spent its six
// seconds here — on a second launch there is nothing left to copy and the
// line reads `0 bytes`.
log::info!(
"bundled models ready: {copied} bytes copied in {} ms",
started.elapsed().as_millis()
);
// Now, and not at launch: the probe fingerprints the model files, and
// on a first launch they were not on disk until this line. The runtime
// is in the APK's native library directory beside `libdarkroom.so`,
// which is also where Qualcomm's DSP loader has to be pointed for the
// Hexagon skel (docs/dev/inference.md §3, §8). Two of them: the QNN
// build, and the generic WebGPU build by its file name, which the
// engine opens only when the first does not fit the SoC (§3.2).
let runtimes = native_library_dir()
.map(|dir| vec![dir.clone(), dir.join("libonnxruntime_generic.so")])
.unwrap_or_default();
dr_ui::inference::init(runtimes);
}
/// The directory the system unpacked this APK's native libraries into.
///
/// Read from where the loader put *this* library rather than asked of the
/// activity: `android-activity` does not expose `nativeLibraryDir`, and the
/// answer is in `/proc/self/maps` for free.
#[cfg(target_os = "android")]
fn native_library_dir() -> Option<std::path::PathBuf> {
let maps = std::fs::read_to_string("/proc/self/maps").ok()?;
maps.lines()
.filter_map(|l| l.split_whitespace().nth(5))
.find(|p| p.ends_with("/libdarkroom.so"))
.and_then(|p| std::path::Path::new(p).parent().map(Into::into))
}
/// 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())
}
/// From API 31 the linker refuses a vendor library the manifest does not
/// name, and QNN cannot create its Hexagon device without the DSP's RPC
/// library: 0.22.0 ran every model on the CPU for want of this line.
#[test]
fn the_npu_can_reach_the_dsp() {
assert!(
manifest().contains(
r#"<uses-native-library android:name="libcdsprpc.so" android:required="false" />"#
),
"libcdsprpc.so must be declared, and not required"
);
}
#[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"
);
}
/// `dr_ui::manual` starts the manual by class name. A name the manifest
/// does not declare is an `ActivityNotFoundException` on the device and a
/// Manual button that does nothing, so the three spellings — dr_ui's, the
/// manifest's and the Java file's — are checked to be one.
#[test]
fn the_manual_activity_dr_ui_starts_is_declared() {
let manifest = manifest();
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
let element = manifest
.split("<activity")
.skip(1)
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
assert_eq!(
attribute(element, "android:exported").as_deref(),
Some("false"),
"the manual activity has no reason to be startable by another app"
);
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
assert!(java.contains(&format!("package {package};")));
assert!(java.contains(&format!("class {class} ")));
assert!(
java.contains(&format!(
"EXTRA_ANCHOR = \"{}\"",
dr_ui::manual::ANDROID_EXTRA_ANCHOR
)),
"ManualActivity reads the section from a different extra than dr_ui writes"
);
}
#[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"
);
}
}