//! 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//files` needs `run-as` against a debuggable build or // root to read, and `/sdcard/Android/data//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. 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/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 int8 forms beside the three detectors are what the Hexagon runs // (docs/dev/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/dev/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 { 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("") { Some(end) => rest = &rest[start + end + 3..], None => { rest = ""; break; } } } out.push_str(rest); out.split_whitespace().collect::>().join(" ") } /// The body of each ``, 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("") .skip(1) .filter_map(|filter| filter.split("").next()) .collect() } /// The single `` element, attributes and all. fn provider(manifest: &str) -> String { let start = manifest .find(" in the manifest"); let rest = &manifest[start..]; let end = rest.find("/>").expect("unterminated element"); rest[..end + 2].to_string() } fn attribute(element: &str, name: &str) -> Option { 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" ); } /// `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("