FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and kills the process if it is not given; until now nothing listened, so the answer was always "no". A tiered registry answers instead: GPU caches first, then proxies, then thumbnails, driven from android_main on MainEvent::LowMemory and MainEvent::Stop. The order is the argument. A backgrounded app has no window to draw and therefore no use for a render pipeline, while its thumbnails are exactly what the user will be looking at half a second after they come back -- so going into the background frees only the GPU tier, and only being measured against death frees everything. Sinks register beside the cache they free and hold weak handles, so the registry cannot keep a controller -- and every decoded portrait in it -- alive past the interface it belonged to. `try_borrow_mut` and skip: a warning can land mid-render, freeing textures under the code drawing with them is worse than missing one, and a warning not acted on is always followed by another. The GPU test is the one that matters: an eviction must change no pixel. A freed intermediate pool whose `colour_key` promise still stands renders an empty texture, and nothing else would have caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
8.6 KiB
Rust
182 lines
8.6 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|