Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,9 +747,56 @@ state - upstream's own 13ac02e8 already made the layers panel a VelaMenu):
(raises the ceiling ~2x); don't remove it. (2) **Any Overpass / large-HTTP-body reader MUST
stream-parse** - `Json.decodeFromStream(body.byteStream())` into a tiny `@Serializable` DTO, NEVER
`resp.body.string()` + `parseToJsonElement` (that held ~5-10x the wire size in transient heap and
OOM'd mid-read - the Flock `out body` fetch per pan did this; fixed in `OverpassAlprCameras`,
`OverpassTrafficSignals`/`OverpassPois` are the same pattern + a pending follow-up). And NEVER lower a
OOM'd mid-read - the Flock `out body` fetch per pan did this). And NEVER lower a
per-viewport Overpass fetch's min-zoom without shrinking the box.
**The Overpass follow-up is DONE (audited 2026-07-20):** `OverpassAlprCameras`, `OverpassTrafficSignals`
AND `OverpassPois` all `decodeFromStream` today. This line previously said the latter two were pending,
which sent a low-RAM investigation chasing already-fixed code. The remaining fully-buffered hot reader
is the GOOGLE ambient path, not Overpass: `GoogleMapsDataSource.get()` ends in `.string()`, and
`GoogleResponse.parse` then makes a `substring` copy plus a full `JsonElement` DOM - times a 15-term
fan-out per pan. That, not Overpass, is the ~180 MB/12 s. It cannot simply `decodeFromStream`: the
payload is a positional nameless array walked by `at(0,1,3)` paths, so there is no DTO to decode into.
The levers that DO move it are the term count and the `!7i` pool size.

- **Low-RAM devices are a FIRST-CLASS target, and the app now adapts to them (issue #83, 2026-07-20).**
D-pad-first means feature-phone-first, and those phones are memory-poor as well as small.
- **`app/ui/MemoryPressure.kt` is the one seam.** `init()` from `VelaApp` classifies the device
(`ActivityManager.isLowRamDevice` OR heap class <= 127 MB) and `VelaApp.onTrimMemory` fans every
`TRIM_MEMORY_*` out to registered holders. **Anything that allocates something large or NATIVE
must register a release callback.** Registration, never a Hilt entry point: reaching a singleton
from a trim would CONSTRUCT it, so the trim would allocate the very thing it is freeing.
- `LowRamMode.enabled` (`:core`) is the `:core`-visible mirror, pushed in by `VelaApp` - same seam
as `CategoryFilter.enabled`, because `:core` must never read an `:app` holder.
- **Verify the low-RAM path or it ships unverified.** Every dev phone we own reports
`lowRam=false heapClassMb=256`, so those branches are dead code locally. Debug builds honour
`adb shell setprop debug.vela.lowram true` (then relaunch); `setprop debug.vela.lowram false`
restores real detection. NB `setprop <name> ""` is a syntax error, not a reset.
- **Measuring: `am send-trim-memory` REFUSES background levels on a foreground process**
("Unable to set a background trim level on a foreground process"). Press HOME first. A harness
that discards that stderr measures NOTHING and reports a clean baseline - this happened here and
produced a whole benchmark of void numbers before the error was noticed. Always check it.
- Measured on an M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts) main vs the fix,
low-RAM path: peak PSS 831 MB -> 581 MB (-30%), post-trim 397 MB -> 246 MB (-38%), native heap
223 MB -> 95 MB (-57%), cold start 4811 ms -> 4333 ms. Idle PSS run-to-run variance is +-60 MB,
so single idle readings prove nothing; compare post-trim, which is paired within a run.
- **The ASR model is the single largest reclaimable allocation: ~267 MB PSS** (~101 MB of weights
in `scudo:secondary` plus ~146 MB of onnxruntime arena in `scudo:primary`). It releases on a
severe trim, is NOT warmed at startup on a low-RAM device, and - on EVERY device - is dropped
after `REAP_IDLE_MS` (120 s) unused and rebuilt on next use. `WhisperRecognizer.release()`
declines while a listen is in flight - freeing the native recognizer under a running decode is a
use-after-free that takes the process down rather than throwing.
- **Do not reach for a device gate when an IDLE gate will do.** The warm-at-startup behaviour was
first made low-RAM-conditional, which protected the instant-first-mic-tap UX on roomier phones
but left them holding 267 MB all session. Reaping on idle keeps that UX AND reclaims the memory
everywhere: device-verified on the 2.9 GB M5, `scudo:secondary` 111 MB -> 9 MB at the 120 s mark
with the model rebuilding on next use. Idle PSS 421 MB -> 299 MB (-29%) on a NON-low-RAM device.
Ask "can this be released when unused?" before "which devices should get less?".
- **`MapView.onLowMemory()` must be called.** MapLibre's tile/glyph/sprite caches are native and
that is the only way to shrink them; nothing called it before.
- **When you gate a category fan-out down, check what has no SECOND source.** The low-RAM ambient
subset keeps `school` and `park` on purpose: the ambient layer filter-hides the basemap OSM poi
layers at z14+, so those two would vanish entirely. A first 6-term subset did exactly that and
was caught by an A/B screenshot, not by any test.

## Layout

Expand Down
28 changes: 26 additions & 2 deletions app/src/main/java/app/vela/VelaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,34 @@ class VelaApp : Application(), coil.ImageLoaderFactory {
* ~128 MB of decoded gallery bitmaps by design, which is most of the "rapid place churn
* runs into the ceiling" OOM (issue #182; measured: 3 gallery-bearing places grew the live
* Dalvik heap 14 -> 94 MB). 48 MB still holds a couple of screens of thumbnails + a hero
* or two; everything else re-decodes from Coil's disk cache, which is untouched. */
* or two; everything else re-decodes from Coil's disk cache, which is untouched.
*
* The cap is now a function of the device instead of one constant: a 48 MB bitmap cache is
* reasonable on a 2-3 GB phone and absurd on a keypad phone whose whole heap class is 96 MB
* (issue #83). Low-RAM devices get 16 MB, which still covers a screen of result thumbnails.
* [MemoryPressure.init] must run before this, and does - onCreate inits it first. */
override fun newImageLoader(): coil.ImageLoader = coil.ImageLoader.Builder(this)
.memoryCache {
coil.memory.MemoryCache.Builder(this)
.maxSizeBytes(48 * 1024 * 1024)
.maxSizeBytes(if (app.vela.ui.MemoryPressure.lowRam) 16 * 1024 * 1024 else 48 * 1024 * 1024)
.build()
}
.build()

/**
* Hand OS memory pressure to every holder that owns a large or native allocation (issue #83).
* Before this existed nothing in the app implemented ComponentCallbacks2, so a
* TRIM_MEMORY_COMPLETE released nothing at all and the OS had no option but to kill us.
* Coil's own cache is trimmed here; everything else releases through [MemoryPressure].
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
app.vela.ui.MemoryPressure.dispatch(level)
if (app.vela.ui.MemoryPressure.isSevere(level)) {
runCatching { coil.Coil.imageLoader(this).memoryCache?.clear() }
}
}

/** Apply the persisted in-app language to the Application context too (no-op when following the
* system), so `getString` from the ViewModel/nav-notification also localizes - resolved at launch
* from the saved pref (an in-session change re-reads it on next launch). */
Expand All @@ -64,6 +83,11 @@ class VelaApp : Application(), coil.ImageLoaderFactory {
Timber.plant(DiagTree(diag))
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())

// Device memory class first: the Coil cap and the eager-warm decisions below both read it.
app.vela.ui.MemoryPressure.init(this)
// Push the device class down to :core, which cannot read an :app holder (same seam as
// CategoryFilter.enabled). Gates the ambient POI fan-out in GoogleMapsDataSource.
app.vela.core.data.LowRamMode.enabled = app.vela.ui.MemoryPressure.lowRam
Units.init(this)
AppTheme.init(this)
AppLocale.init(this) // resolve the app language (system default) → drives the nav-text locale
Expand Down
110 changes: 110 additions & 0 deletions app/src/main/java/app/vela/ui/MemoryPressure.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package app.vela.ui

import android.app.ActivityManager
import android.content.ComponentCallbacks2
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
import timber.log.Timber

/**
* Process-wide memory-pressure fan-out, in the same shape as the other app-level holders
* (`TransitLayer`, `AppTheme`): `init()` from `VelaApp`, then anything holding a large or native
* allocation registers a release callback.
*
* Why registration and not a Hilt entry point: reaching `WhisperRecognizer`/`PiperSynth` from
* `onTrimMemory` through an EntryPoint would CONSTRUCT them if they had never been used, so a
* trim would allocate the very models it is trying to free. A holder registers only once it
* actually owns something worth releasing, so a trim can never create work.
*
* Measured on the M5 (2.9 GB, Android 13, standardDebug) before this existed: TRIM_MEMORY_COMPLETE
* released 0 KB, because nothing in the app implemented ComponentCallbacks2 at all.
*/
object MemoryPressure {

/** A registered releaser. [level] is a `ComponentCallbacks2.TRIM_MEMORY_*` constant. */
fun interface Listener {
fun release(level: Int)
}

private val listeners = CopyOnWriteArrayList<Listener>()

/**
* True when the OS classes this device as low-RAM (`ActivityManager.isLowRamDevice`) OR its
* heap class is small enough that our normal budgets do not fit. The heap-class arm matters:
* plenty of cheap keypad phones do NOT set the low-RAM system property yet still hand out a
* 96 MB heap class, and those are exactly the phones this work is for.
*/
@Volatile var lowRam: Boolean = false
private set

/** The device's normal (non-large) heap class in MB. 0 until [init]. */
@Volatile var heapClassMb: Int = 0
private set

fun init(context: Context) {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
heapClassMb = am?.memoryClass ?: 0
val forced = forcedLowRam()
lowRam = forced ?: ((am?.isLowRamDevice == true) || (heapClassMb in 1..127))
Timber.i(
"MemoryPressure init lowRam=%b heapClassMb=%d forced=%s",
lowRam, heapClassMb, forced?.toString() ?: "no",
)
}

/**
* Debug-only override so the low-RAM path can be exercised on a normal dev phone:
*
* adb shell setprop debug.vela.lowram true # then relaunch the app
* adb shell setprop debug.vela.lowram "" # back to real detection
*
* Without this the low-RAM branches are dead code on every device we actually own (the M5 dev
* phone reports heapClassMb=256, lowRam=false), which means they would ship unverified. Returns
* null when unset or on a non-debug build, so release behaviour is untouched.
*/
private fun forcedLowRam(): Boolean? {
if (!app.vela.BuildConfig.DEBUG) return null
val v = runCatching {
@Suppress("PrivateApi")
val sp = Class.forName("android.os.SystemProperties")
sp.getMethod("get", String::class.java).invoke(null, "debug.vela.lowram") as? String
}.getOrNull()
return when (v?.lowercase()) {
"true", "1" -> true
"false", "0" -> false
else -> null
}
}

/** Register [listener]; returns a handle whose `close()` unregisters. Safe to call any time. */
fun register(listener: Listener): AutoCloseable {
listeners.add(listener)
return AutoCloseable { listeners.remove(listener) }
}

/**
* Fan a trim out to every registered holder. Each listener is isolated: one throwing must not
* stop the rest from releasing, since under real pressure we want every byte we can get.
*/
fun dispatch(level: Int) {
Timber.i("MemoryPressure dispatch level=%d listeners=%d", level, listeners.size)
for (l in listeners) {
runCatching { l.release(level) }
.onFailure { Timber.w(it, "MemoryPressure listener failed") }
}
}

/**
* The app is backgrounded or the OS is genuinely short of memory, so caches that only speed
* things up should go. Everything at or above this level is a "drop it" signal.
*/
fun isSevere(level: Int): Boolean =
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW

/** Only the harshest levels, where we drop things that cost real time to rebuild. */
fun isCritical(level: Int): Boolean =
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL
}
15 changes: 13 additions & 2 deletions app/src/main/java/app/vela/ui/map/MapViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1233,8 +1233,14 @@ class MapViewModel @Inject constructor(
// popular times AND the photo gallery land faster when the user taps a result
// (both idempotent; the photo warm primes the renderer + HTTP/2 sockets + cache
// so the first place page skips the cold start).
viewModelScope.launch { runCatching { webPopularTimes.prewarm() } }
runCatching { webPhotos.warm() }
// Skipped on low-RAM devices: each warm spins up a Chromium renderer SPECULATIVELY, on the
// guess that a search predicts a place tap. When memory is the scarce resource that trade is
// backwards - the user pays two renderers on every search whether or not they open anything
// (issue #83). Those phones build the WebView on first real use instead.
if (!app.vela.ui.MemoryPressure.lowRam) {
viewModelScope.launch { runCatching { webPopularTimes.prewarm() } }
runCatching { webPhotos.warm() }
}
searchJob?.cancel()
searchJob = viewModelScope.launch {
// A fresh typed search leaves any along-route browse: picks open places normally again.
Expand Down Expand Up @@ -4468,6 +4474,11 @@ class MapViewModel @Inject constructor(
/** Remove one engine's model (Settings "Remove"); the active pick degrades to another installed
* engine automatically (AsrEngine.active). */
fun deleteAsrEngine(engine: app.vela.voice.AsrEngine) {
// Free the loaded model BEFORE removing its files. Deleting the directory alone left the
// native recognizer resident for the rest of the process (~267 MB measured, issue #83), so
// "Remove" reclaimed disk but no memory at all. Released unconditionally: the loaded engine
// may not be [engine], but the worst case is a ~1 s reload on the next listen.
whisperRecognizer.release()
engine.dir(appContext).deleteRecursively()
refreshAsr()
}
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/vela/ui/map/VelaMapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,17 @@ fun VelaMapView(
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// MapLibre keeps its tile, glyph and sprite caches in NATIVE memory, and the only way to ask
// it to shrink them is onLowMemory(). Nothing called it before (issue #83), so the map held
// its full cache through every trim the OS sent. Registered with the map's own lifecycle so
// the listener can never outlive the MapView it points at.
val trim = app.vela.ui.MemoryPressure.register { level ->
if (app.vela.ui.MemoryPressure.isSevere(level)) {
runCatching { mapView.onLowMemory() }
}
}
onDispose {
trim.close()
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onPause()
mapView.onStop()
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/app/vela/voice/PiperSynth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ class PiperSynth @Inject constructor(
@Volatile private var loadFailed = false
@Volatile private var generation = 0

init {
// The Piper VITS model is the app's second-largest native holding after the ASR model.
// CRITICAL only, deliberately narrower than the recognizer's severe trigger: dropping the
// synth costs a reload on the next prompt, and a prompt arriving late during navigation is
// a missed turn. TRIM_MEMORY_COMPLETE only reaches background processes, and
// RUNNING_CRITICAL means the device is about to start killing things regardless.
// release() posts to the piper-tts worker, so it is already serialized against an
// in-flight synthesis and cannot free the model out from under one (issue #83).
app.vela.ui.MemoryPressure.register { level ->
if (app.vela.ui.MemoryPressure.isCritical(level)) release()
}
}

/** Which voice id `tts` currently holds - lets [ensureLoaded] detect a voice switch and rebuild. */
@Volatile private var loadedVoiceId: String? = null

Expand Down
Loading
Loading