Give memory back in the order the user will miss it least

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>
This commit is contained in:
2026-08-29 23:20:57 +02:00
co-authored by Claude Opus 5
parent 55cb9b5b30
commit 085ab766b3
8 changed files with 524 additions and 1 deletions
+41 -1
View File
@@ -51,7 +51,47 @@ fn android_main(app: slint::android::AndroidApp) {
// 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) {
// 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;
}