Merge pull request #3503 from vitorpamplona/claude/appmodules-memory-trim-deprecations-7301bk

Adapt memory trimming to Android 14+ trim level changes
This commit is contained in:
Vitor Pamplona
2026-07-08 17:29:43 -04:00
committed by GitHub
5 changed files with 46 additions and 74 deletions
@@ -155,13 +155,11 @@ class Amethyst : Application() {
if (isNappletSandbox) return
instance.trim(level)
// Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the
// user or Android reclaims them). Deliberately NOT on UI_HIDDEN/BACKGROUND — those fire on every
// backgrounding, and a pinned tab should survive that. Only on real pressure levels, R+ only.
val pressure =
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
level == ComponentCallbacks2.TRIM_MEMORY_MODERATE ||
level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE
// user or Android reclaims them). Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND:
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
// a plain backgrounding. R+ only.
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.evictAll()
}
@@ -912,61 +912,36 @@ class AppModules(
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
// Trim in-process caches proportional to OS memory pressure.
//
// Background levels (app not visible, ordered highest-first so the when
// chain short-circuits at the right tier):
// COMPLETE (80) — at the bottom of the LRU list, kill imminent
// MODERATE (60) — system is hurting, neighbouring apps being killed
// BACKGROUND(40) — backgrounded, mild system pressure
// UI_HIDDEN (20) — just backgrounded, no pressure yet
// Since API 34 the OS only ever delivers two trim levels (the foreground
// RUNNING_* levels and the deeper MODERATE/COMPLETE background tiers were
// deprecated because apps are no longer notified of them):
// BACKGROUND(40) — process is on the system LRU list: real reclaim
// pressure, and the strongest signal we still get.
// UI_HIDDEN (20) — just backgrounded, no pressure yet. Fires on EVERY
// app switch.
//
// Foreground levels (app is active but system is low):
// RUNNING_CRITICAL (15), RUNNING_LOW (10)
//
// UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches
// (Robohash SVG assembly, rich-text parsing) there — clearing them
// forces a full rebuild on every resume and causes visible jank.
// So we key off exactly those two. UI_HIDDEN is frequent, so it only trims
// images (bitmaps are the largest allocations) and keeps the CPU-heavy
// caches (Robohash SVG assembly, rich-text parsing) warm — clearing them
// would force a full rebuild on every resume and cause visible jank.
// BACKGROUND trims hard but keeps a small working set: it means "on the LRU
// list" (real reclaim pressure), not the imminent kill that COMPLETE used to
// signal — so leave just enough warm to redraw the screen the user left on.
when {
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
// Kill imminent: free everything.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(0)
CachedRobohash.trimToSize(0)
nip11Cache.trimToSize(0)
}
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> {
// System under real pressure: clear images and most parsed state.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(50)
CachedRobohash.trimToSize(10)
nip11Cache.trimToSize(100)
}
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
// Backgrounded with mild pressure: trim significantly.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
// On the LRU list under real pressure: trim hard, but keep a small
// working set so a returning user doesn't rebuild the visible screen
// from scratch. memoryCache is byte-sized (Coil), the rest are entry counts.
memoryCache.trimToSize(memoryCache.maxSize / 10)
CachedRichTextParser.trimToSize(10)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(200)
nip11Cache.trimToSize(10)
}
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// Just backgrounded, no pressure yet: trim images (bitmaps are the
// largest allocations) but keep parsed-text and avatar caches warm
// so resuming is instant.
// Just backgrounded, no pressure yet: trim images but keep the
// parsed-text and avatar caches warm so resuming is instant.
memoryCache.trimToSize(memoryCache.maxSize / 2)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// Foreground, critically low memory.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(200)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
// Foreground, low memory.
memoryCache.trimToSize(memoryCache.maxSize / 2)
CachedRichTextParser.trimToSize(250)
CachedRobohash.trimToSize(50)
nip11Cache.trimToSize(500)
}
}
}
}
@@ -34,19 +34,18 @@ class MemoryTrimmingService(
var isTrimmingMemoryMutex = AtomicBoolean(false)
/**
* Tiered pruning scaled to the OS memory-pressure level.
* Two-tier pruning keyed to the OS trim levels still delivered since API 34
* (the foreground RUNNING_* and deeper MODERATE/COMPLETE levels were deprecated
* because apps are no longer notified of them).
*
* Tier 1 — mild pressure (UI hidden, running-moderate):
* Tier 1 — UI hidden (fires on every app switch):
* Sweep stale WeakRefs, drop expired and superseded-replaceable events.
* Safe to run frequently; no UI-visible side effects.
*
* Tier 2 — low memory (running-low, background):
* Tier 1 + old chat messages + unobserved thread replies / reactions.
* May cause feeds to re-fetch content that was scrolled past.
*
* Tier 3 — critical / imminent kill (running-critical, moderate, complete):
* Tier 2 + sever all observer links + drop every event from muted/blocked users.
* Aggressive; triggers recomposition wherever StateFlows were cleared.
* Tier 2 — background / real reclaim pressure (process on the LRU list):
* Tier 1 + drop events from muted/blocked users + old chat messages +
* unobserved thread replies / reactions. May cause feeds to re-fetch content
* that was scrolled past; triggers recomposition wherever StateFlows cleared.
*/
private fun doTrim(
account: Collection<Account>,
@@ -60,16 +59,13 @@ class MemoryTrimmingService(
cache.pruneExpiredEvents()
cache.prunePastVersionsOfReplaceables()
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
// Tier 2: medium pressure — drop events from muted/blocked users
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
// messages, and unobserved reactions.
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
}
}
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
// Tier 3: critical pressure — drop old messages and unobserved reactions
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
@@ -79,7 +75,7 @@ class MemoryTrimmingService(
suspend fun run(
account: Collection<Account>,
otherAccounts: List<AccountInfo>,
level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
) {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
Log.d("ServiceManager", "Trimming Memory (level=$level)")
@@ -166,7 +166,9 @@ class PlaybackService : MediaSessionService() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
// Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND; BACKGROUND (process on
// the system LRU list) is the real reclaim-pressure signal, so release the warm pool then.
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
poolNoProxy?.exoPlayerPool?.releaseWarmPool()
poolWithProxy?.exoPlayerPool?.releaseWarmPool()
}
@@ -143,11 +143,12 @@ class AccountFeedContentStates(
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
init {
// Under critical memory pressure, trim every feed down to 50 items to release
// the strong Note references that would otherwise keep pruned cache objects alive.
// Under real memory pressure (process on the system LRU list — the strongest trim
// level the OS still delivers since API 34), trim every feed down to release the
// strong Note references that would otherwise keep pruned cache objects alive.
scope.launch(Dispatchers.IO) {
Amethyst.instance.trimLevelEvents.collect { level ->
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
trimFeedsToSize(200)
}
}