Files
DarkRoom/apps/darkroom-android/src/lib.rs
T
dtourolleandClaude Opus 5 eaafacc3fb Give the phone the model it had no way to obtain
Face indexing was compiled into the APK all along — dr-ui takes dr-face with
`inference` on every target, so SCRFD, alignment, MBF, calibration and
clustering were all in there. What was missing was the weights, and on Android
there was no way to supply them.

Route C (docs/faces.md §2.2) says the user obtains the model and the app loads
it. On a desktop that is a real gesture: drop two files in
~/.local/share/darkroom/models/ and indexing starts working. On Android it is
not a gesture at all. `internal_data_path` is app-private, `run-as` needs a
debuggable build, and the in-app fetch route C specifies was never built — so
the settings page reported "no face model is installed" on every launch with
nothing behind the message. Not "off until you supply weights"; off.

So the shape-fixed pair goes into LFS under the APK's assets, assemble-apk.sh
copies it into the package, and `android_main` unpacks it to the shared models
directory before anything asks whether a model is present.

Three things that are not incidental:

The models directory is now shared across accounts rather than per-account.
Weights are identified by `faces.model_id`, not by who is signed in, so two
accounts had no reason to hold two copies — and the unpack runs before any
session exists to key a per-account path off. `face_models` still prefers a
per-account directory when one is populated, so anyone mid-migration keeps the
ability to pin one library to its own pair.

The unpack writes under a temporary name and renames. `face_models` decides
availability on `is_file()` alone, so a copy truncated by the process being
killed would leave a file that passes that test and fails inside tract —
reported to the user as a broken model rather than a missing one.

assemble-apk.sh refuses an LFS pointer. At ~130 bytes it looks exactly like a
model to `cp`, and unchecked it reaches the device and fails in the graph
loader instead of telling someone to run `git lfs pull` — the same guard
dr-segment's build script applies to yolo26n-seg.onnx.

The licensing half is unchanged and recorded in §2.2a: the InsightFace grant is
research-only, this is a private repository and a self-installed build, and
these files come back out before anything is published. The weights are still
not a cargo build input — dr-face has no `models/` directory and no
`embedded-model` feature, and nothing in the build reads them. The APK assembly
step copies two files and is the only thing in the tree that knows they exist.

Verified on device: both models unpack on first launch (2524817 and 13616095
bytes) and the APK carries them at assets/models/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:18:07 +02:00

142 lines
6.3 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. `env_logger` writes to stderr, which Android
//! discards.
// `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) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
// 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.
std::panic::set_hook(Box::new(|info| {
log::error!("panic: {info}");
}));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// 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());
dr_sync_nextcloud::session::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
// After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app);
if let Err(e) = slint::android::init(app) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
// Empty rather than the desktop's argv: see the module note above.
//
// 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()) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
/// Unpack the face models the APK carries, if it carries any.
///
/// # Why Android needs this and no other platform does
///
/// The weights are not a build input and are not in the repository — 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 into
/// `~/.local/share/darkroom/models/`. **That gesture does not exist on
/// Android.** `internal_data_path` is app-private, `run-as` needs a debuggable
/// build, and there is no picker and no fetch in the app, so a phone had no way
/// to acquire a model at all and face indexing reported itself permanently off.
///
/// So a locally-built APK may carry the pair in `assets/models/`, which
/// `assemble-apk.sh` includes when the tree has them and omits when it does
/// not. Nothing changes about what the repository holds or what a published
/// build could redistribute; this only gives a self-built APK the same route a
/// desktop build has always had.
///
/// Absent assets are the ordinary case, not an error — the same quiet "no model
/// installed" state a fresh desktop install is in.
#[cfg(target_os = "android")]
fn install_bundled_face_models(app: &slint::android::AndroidApp) {
use std::io::Read;
// The **shape-fixed** names, 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`.
const BUNDLED: [(&std::ffi::CStr, &str); 2] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
];
let dir = dr_ui::shared_face_models_dir();
let assets = app.asset_manager();
for (asset_path, name) in BUNDLED {
let dest = dir.join(name);
// Already unpacked. Not re-read on every launch: this is 15 MB through
// a decompressor on the startup path, and the file does not change
// without the APK changing, at which point the install wiped it anyway.
if dest.is_file() {
continue;
}
let Some(mut asset) = assets.open(asset_path) else {
log::info!("no bundled {name} in this APK; face indexing 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` decides face indexing 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(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()),
Err(e) => {
log::error!("cannot install {name}: {e}");
let _ = std::fs::remove_file(&part);
}
}
}
}