Files
DarkRoom/apps/darkroom-android/src/lib.rs
T
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +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::account::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);
}
}
}
}