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>
This commit is contained in:
2026-08-27 13:18:07 +02:00
co-authored by Claude Opus 5
parent b846b312b8
commit eaafacc3fb
10 changed files with 287 additions and 64 deletions
+7
View File
@@ -55,6 +55,13 @@ use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
/// Where a model has to land for face indexing to find it, on any account.
///
/// Public for the Android entry point, which is the only caller that knows the
/// APK may carry a bundled copy and has to write it out before any store is
/// opened — see `library::shared_face_models_dir`.
pub use library::shared_face_models_dir;
pub mod launch;
pub mod launch_ui;
+49 -24
View File
@@ -916,32 +916,38 @@ pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
// **Not the cache directory, and on Android that distinction is the whole
// point.** Neither `XDG_DATA_HOME` nor `HOME` is set there, so this used
// to fall through to `temp_dir()` — which Android resolves to the app's
// *cache*, a directory the system deletes without asking under storage
// pressure.
//
// What sits beside this catalog is not disposable. `sidecars/` is the
// commit point for every rating and edit made offline (see
// `sidecar_cache`), and `outbox/` holds exports the user has been told
// succeeded. A day of culling on a train, evicted by the OS before it ever
// reached the server, is the worst failure this application can have, and
// it would be silent.
//
// `SessionStore::data_dir()` is the persistent per-app directory the
// Android entry point establishes before anything opens a store. On a
// desktop it is the XDG config directory, and the two lines below keep the
// established XDG *data* location there rather than moving anyone's
// catalog.
data_root()
.join(format!("{slug}-{user_id}"))
.join("catalog.sqlite")
}
/// The directory every account's data hangs off.
///
/// **Not the cache directory, and on Android that distinction is the whole
/// point.** Neither `XDG_DATA_HOME` nor `HOME` is set there, so this used
/// to fall through to `temp_dir()` — which Android resolves to the app's
/// *cache*, a directory the system deletes without asking under storage
/// pressure.
///
/// What sits beside a catalog is not disposable. `sidecars/` is the
/// commit point for every rating and edit made offline (see
/// `sidecar_cache`), and `outbox/` holds exports the user has been told
/// succeeded. A day of culling on a train, evicted by the OS before it ever
/// reached the server, is the worst failure this application can have, and
/// it would be silent.
///
/// `SessionStore::data_dir()` is the persistent per-app directory the
/// Android entry point establishes before anything opens a store. On a
/// desktop it is the XDG config directory, and the two lines below keep the
/// established XDG *data* location there rather than moving anyone's
/// catalog.
fn data_root() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.unwrap_or_else(dr_sync_nextcloud::session::SessionStore::data_dir);
base.join("darkroom")
.join(format!("{slug}-{user_id}"))
.join("catalog.sqlite")
}
/// TRACES: FR-NC-10 | NFR-R1
@@ -2906,6 +2912,18 @@ pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-models"))
}
/// Where face models live for *every* account on this device.
///
/// Account-independent, unlike the catalog: a model is identified by
/// `faces.model_id` (catalog.md §10.1) and not by who is signed in, so two
/// accounts have no reason to hold two 15 MB copies of the same weights. This
/// is also the only directory an Android build can populate for itself — the
/// entry point extracts the APK's bundled copy here before any store opens,
/// and at that moment no session exists to key a per-account path off.
pub fn shared_face_models_dir() -> PathBuf {
data_root().join("models")
}
/// The detector and embedder files, if both are present.
///
/// Both or neither: an embedder with no detector has nothing to embed, and a
@@ -2915,11 +2933,18 @@ pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
/// The names are the **shape-fixed** exports, not what InsightFace ships:
/// `tools/fix-face-model-shapes.sh` has to run over the originals first,
/// because tract cannot parse either graph with a dynamic input.
///
/// The per-account directory wins over the shared one so a library can be
/// pinned to its own weights — a re-index after a model swap is a `model_id`
/// change, and someone mid-migration needs the old pair to stay put for one
/// account without holding back the other.
pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> {
let dir = face_models_dir(server, user_id);
let detector = dir.join("scrfd_500m_640.onnx");
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> {
let detector = dir.join("scrfd_500m_640.onnx");
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
}
pair(face_models_dir(server, user_id)).or_else(|| pair(shared_face_models_dir()))
}
/// One grid cell's data, read from the catalog.