Benchmarks / CPU and I/O (per commit) (push) Successful in 3m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 31m59s
Build and test / Layer separation (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 57s
Build and test / Android (aarch64) (push) Failing after 54m6s
Build and test / Windows (x86_64, cross) (push) Failing after 1h4m55s
The unpack list gained migan-512.onnx without its length following; nothing on the desktop compiles that crate, and the first Android build of 0.13.0 stopped there.
603 lines
29 KiB
Rust
603 lines
29 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/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.
|
|
std::thread::spawn(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/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/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 int8 forms beside the three detectors are what the Hexagon runs
|
|
// (docs/inference.md §5); the engine loads the sibling when the probe
|
|
// chose that rung and ignores it otherwise.
|
|
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
|
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
|
(
|
|
c"models/scrfd_500m_640.int8.onnx",
|
|
"scrfd_500m_640.int8.onnx",
|
|
),
|
|
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
|
|
(
|
|
c"models/scrfd_2.5g_640.int8.onnx",
|
|
"scrfd_2.5g_640.int8.onnx",
|
|
),
|
|
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
|
|
(c"models/scrfd_10g_640.int8.onnx", "scrfd_10g_640.int8.onnx"),
|
|
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
|
(c"models/2d106det_b1.onnx", "2d106det_b1.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.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"),
|
|
];
|
|
|
|
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/inference.md §3, §8).
|
|
dr_ui::inference::init(native_library_dir().into_iter().collect());
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|