This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Use a lot less memory, give it back under pressure, and make voice work on 32-bit phones - #86
Merged
Merged
Conversation
Reported on a TCL Flip 2 as "the whole app is a bit slow" (#83). Measured on an M5 (2.9 GB, Android 13, standardDebug, median of 5 cold starts) the app held 831 MB at peak, sat at 421 MB idle, and gave back nothing at all when the OS asked it to shrink, because nothing in the tree implemented memory-pressure handling. The biggest win needs no device gate. The on-device speech model costs ~267 MB while loaded (~101 MB of weights plus ~146 MB of onnxruntime arena) and was kept for the whole process on the chance of a mic tap many users never make. It is now dropped after two minutes unused and rebuilt on next use, so the instant first tap that was asked for on 2026-07-10 is kept while the session-long hold is not. Device-verified: scudo:secondary 111 MB to 9 MB at the 120 s mark, model rebuilding correctly afterwards. Idle PSS 421 MB to 299 MB on a phone that is not low-RAM at all. Nothing released under pressure. There was no onTrimMemory, onLowMemory or ComponentCallbacks2 anywhere, so a TRIM_MEMORY_COMPLETE freed 0 KB and the system's only remaining option was to kill us. VelaApp now fans every trim out through a new MemoryPressure holder, and the things that actually hold memory register a release: the speech model, the neural voice, MapLibre's native tile and sprite caches (MapView.onLowMemory was never called), all five hidden WebViews, and the image cache. WhisperRecognizer had no release path at all, so even Remove-model left ~267 MB resident for the rest of the process. Nothing adapted to the device either. There was no isLowRamDevice branch and the image cache was a flat 48 MB whatever the phone. Constrained devices now skip the startup preload of the speech model, cap images at 16 MB, skip the speculative WebView warm on every search, and fetch 8 ambient POI category terms instead of 15 with a smaller result pool. Roomier phones keep their existing behaviour. The low-RAM POI subset deliberately keeps school and park: the ambient layer filter-hides the basemap OSM poi layers at z14+, so those two have no second source and a first 6-term subset made every park and school pin vanish. Caught by an A/B screenshot, not by any test. Also stop shipping x86 and x86_64 native libraries. No phone Vela targets can execute them and libmaplibre.so alone carried 23 MB of them into every install. armeabi-v7a stays, since 32-bit ARM keypad phones are real. Measured, main vs this, low-RAM path: peak 831 MB to 581 MB (-30%), post-trim 397 MB to 246 MB (-38%), native heap 223 MB to 95 MB (-57%), cold start 4811 ms to 4333 ms. On a normal-RAM device idle drops 29%, post-trim 28% and native heap 44%. APK 97.4 MB to 92.0 MB. Debug builds honour `setprop debug.vela.lowram true` so the low-RAM path can be exercised on a dev phone, where it is otherwise dead code (every device we own reports lowRam=false heapClassMb=256). AGENTS.md: document the seam and the measurement traps, and correct the memory rule, which said the OverpassTrafficSignals/OverpassPois stream-parse follow-up was pending. It has been done for some time; the remaining buffered hot reader is the Google ambient path, and chasing the stale line wasted a pass.
PR #85 gave every big holder a release() and fanned OS trims out to them, but a Kotlin release only returns pages to scudo. They sit on its free lists, where RSS/PSS still count them and lmkd still sees a fat process. Measured on the M5 before this change: a full TRIM_MEMORY_COMPLETE with all 8 listeners firing moved scudo:primary 56,578 to 54,978 KB while mallinfo reported a 442 MB arena holding just 46 MB live. mallopt() is the only way to hand that gap on and it is reachable only from C, so this adds the app's first native module: app/src/main/cpp/velamem.cpp, three lines calling libc, 4 KB for arm64 and 2.7 KB for armeabi-v7a. Built for those two ABIs only, matching the x86 drop in #85. MemoryPressure.dispatch schedules it 750 ms after a trim, off the main thread. The delay is load-bearing: the WebView reapers post destroy() to the main looper and VelaApp clears Coil after dispatch returns, so an inline purge would run before the memory it is meant to reclaim had been freed. The purge fires from TRIM_MEMORY_RUNNING_LOW (10) up, deliberately wider than isSevere (40). Measured: pressing HOME delivers only TRIM_MEMORY_UI_HIDDEN (20), never BACKGROUND (40), so gating on isSevere would skip the single most common moment we are handed, the one where the app is off-screen and nothing can jank. Verified by A/B on ONE binary, since two builds also differ in background settling and idle PSS swings +-60 MB run to run. debug.vela.nopurge suppresses the purge at runtime, making the delta paired within a run; both arms were checked in logcat to confirm the gate actually gates. Releasing the ASR model, 3 alternating pairs: with the purge suppressed scudo:primary moved 60/32/28 KB in the 7 s after the trim, which is nothing, and with it on 3704/3008/2792 KB. No overlap. A second 8-pair A/B over map and POI churn agreed: 3345 to 6931 KB mean reclaimed, Mann-Whitney U=7 at n=8/8, p<0.05. It is worth a consistent ~3 MB, not tens, and the commit says so rather than claiming the onnxruntime arena. The ASR model's ~111 MB lives in scudo:secondary, which is mmap-backed and comes back on free() with no purge needed (111 MB to 7 MB in BOTH arms). Only scudo:primary needs asking. M_PURGE_ALL is API 34+, so on the Android 13 dev phone it returns 0 and the code falls back to M_PURGE (API 28+). The logged mode= says which actually took, so a device supporting neither is visible instead of silently doing nothing. Also corrects two things #85 left wrong. The MemoryPressure KDoc told the reader to run `setprop debug.vela.lowram ""` for real detection, which AGENTS.md itself says is a shell syntax error; and `false` does not restore detection either, it forces the normal path. Clearing needs an unparseable value, so both the KDoc and AGENTS.md now say `none`, which is verified: the app logs forced=no. AGENTS.md also gains the UI_HIDDEN-vs-BACKGROUND finding, the one-binary A/B rule, and the asr_model_bad trap: a quarantined model makes warmUp() a silent no-op, so scudo:secondary sits at ~11 MB instead of ~111 MB and a memory benchmark measures the model-absent case without saying so. That cost a run here. Verified: :app:detekt and :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, standardDebug builds, installs, launches with no UnsatisfiedLinkError, and renders correctly on device (screenshot: map, POI pins including parks, focus ring, soft keys).
Measured on the M5 while reviewing #83: the hidden scraper WebViews are the single largest thing Vela costs, and app PSS cannot see most of it. Android runs the WebView renderer OUT OF PROCESS, so after one search sandboxed_process0 sat at 305-347 MB PSS, plus webview_service 21 MB and webview_apk 37 MB, none of it in this app's dumpsys meminfo. Every number in #83 was app-PSS only, so the biggest item in the app was invisible to the whole exercise. The app-side half is GL mtrack, from the offscreen layouts: 26 MB with no WebView alive, 437 MB with the two scraper views up. Neither speculative warm was bounded. WebPhotoFetcher had no idle reaper at all, and WebPopularTimesFetcher.prewarm() created a view and never called scheduleReap() (only fetch() did). MapViewModel warms both on every search, so a single search held the renderer until the process died or a trim arrived. Both now arm a reap, and both matter: fixing only the photo fetcher left the renderer alive at ~200 MB because the popular-times view still held it. The renderer is shared by every WebView in the process, so one un-reaped view keeps it up for all of them. Device-measured, no trim involved anywhere in the run: t+45s GL mtrack 437 MB app PSS 725 MB renderer alive t+90s GL mtrack 65 MB app PSS 337 MB reap logged t+135s GL mtrack 64 MB app PSS 335 MB renderer process gone About 390 MB back to the app plus a ~305 MB renderer process shut down, from memory that used to be held for the rest of the session. Rebuild verified: a later search respawns the renderer and GL mtrack returns to 431 MB, no crash. A speculative warm gets a longer window than a real fetch (WARM_REAP_IDLE_MS 300 s vs REAP_IDLE_MS 120 s). The warm exists so the first place tap skips the cold start, and reaping at 120 s would expire during an ordinary browse and waste it. Bounded, not short, is the point: the bug was session-long. reapNow() now drains pending like rendererGone() already did. Destroying the view kills the injected scraper, so nothing completes those deferreds; a reap landing mid-fetch parked the fetch in deferred.await() for the full 40 s TOTAL_TIMEOUT_MS while holding the fetcher's Mutex, stalling everything queued behind it. An empty result is the documented best-effort failure, a 40 s hang is not. This was already reachable from #83's severe-trim hook. Reap bookkeeping moved onto the main thread in both fetchers (onMain). The reap field is touched from the trim listener on main and from fetch/warm on the caller's dispatcher, and scheduling is a read-modify-write that @volatile would not make safe; this commit adds another writer, so it fixes the race rather than widening it. The reap is logged because verifying it needs a log, not a process check. WebView.destroy() does not kill the renderer promptly (220 MB still resident 8 s after a destroy, process gone only minutes later) and an OS trim can kill it for unrelated reasons. The first attempt to verify this was unfalsifiable for that reason: the renderer vanished at t+60 s and the logs showed dispatch level=15/40, a real trim rather than the reaper. The runs above assert the reap log AND assert no severe trim fired. AGENTS.md: the out-of-process measurement rule (always total the WebView processes), the both-warms-or-neither finding, the drain-pending rule, and the log-not-process verification rule. Verified: :app:detekt and :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, audit_dpad static no new violations (the four reported are pre-existing, in files this commit does not touch), standardDebug builds, installs and runs, screenshot of the map after a reap-and-rebuild cycle.
alltechdev
force-pushed
the
perf/webview-and-native-purge
branch
from
July 21, 2026 04:15
d2bfb05 to
92beb2a
Compare
The inFlight guard added with the idle reaper did not actually close the window
it documents. release() read the counter BEFORE taking loadLock, and
ensureRecognizer() handed the native pointer out on a lock-free fast path
(`recognizer?.let { if (loadedLang == lang) return it }`) before taking the lock
at all, so the two never ordered against each other:
reaper release(): inFlight.get() -> 0, guard passes, lock not yet held
user listen(): inFlight.incrementAndGet() -> 1
user ensureRecognizer() fast path returns the live pointer
user decode starts on it
reaper synchronized(loadLock) { recognizer = null; r.release() }
user rec.decode(stream) on freed C++ memory
That is a SIGSEGV inside libsherpa-onnx-jni, not an exception - the runCatching
around the decode cannot catch a native abort, so the whole process dies. An
AtomicInteger does not make a check-then-act atomic.
The window was not narrow either. Any thread holding loadLock for the ~1 s
native load parks release() between its check and its free for that entire time,
and warmUp() takes that lock at startup on every non-low-RAM device.
Fix: leases, mutated only under loadLock. acquireRecognizer() loads and takes a
lease under one lock; release() checks the count and frees under that same lock,
so a listen cannot start between the two. The lock-free fast path is gone - an
uncontended lock per listen is nothing next to a 15 s utterance. listen() takes
the lease and holds it for the whole utterance (recording included) and gives it
back in a finally, which keeps it exception-safe against listenInner's many
early returns; listenInner now receives the recognizer instead of fetching it.
releaseLease() deliberately does NOT take the lock: it runs only once the decode
is done with the pointer, so a racing release() can at worst read the
pre-decrement value and conservatively decline, and taking the lock there would
park the end of every utterance behind an unrelated load.
loadLock becomes a ReentrantLock so release() can tryLock instead of blocking.
It is called from onTrimMemory on the MAIN thread, and blocking the UI thread
for a whole model load to reclaim memory is a bad trade when the idle reaper or
the next trim retries anyway. deleteAsrModel passes wait = true, since there the
user asked for it and a brief wait is correct.
Device-verified on the M5, and the race window is real rather than theoretical:
hammering `am send-trim-memory <pid> RUNNING_CRITICAL` across startup logged
"release skipped, model load in progress" 5 times in one run, i.e. 5 trims
landed while the load held the lock - each of which the old code would have used
to block the main thread, with the check-then-act live. Across that run and a
6-round force-stop/trim-storm: 0 crashes, 0 SIGSEGV, process alive every round.
Release path still works with the model loaded (scudo:secondary 110,564 KB ->
8,031 KB on a severe trim, "recognizer released" logged), the model reloads
cleanly afterwards (108,381 KB) and is not quarantined, and the map renders.
RUNNING_CRITICAL is the useful level for this test: it is isSevere AND the OS
accepts it on a foreground process, so it reaches the load window without
needing HOME first.
Verified: :app:detekt and :core:detekt 0 smells, :core:test green,
audit_deadcode.sh PASS, standardDebug builds, installs and runs, screenshot.
WebPhotoFetcher sized its view inside ensureWebView(), i.e. at construction, and warm() goes through ensureWebView(). So a search built a full 1200x3200 composited surface over maps?hl=en - a page with no scrapeable content on it - and held it for the whole 300 s warm window, on a phone whose screen is 480x640. Sizing moves into sizeForScrape(wv), called in fetch() immediately before loadUrl, so the ?cid= page's FIRST layout is already at scrape geometry. The size is unchanged; only when it is applied changes. Matched A/B, same harness, 3 runs per arm, search then browse with no place opened: eager (as before) GL mtrack 448 / 427 / 441 MB TOTAL PSS 866 / 854 / 852 MB deferred GL mtrack 77 / 71 / 72 MB TOTAL PSS 485 / 460 / 459 MB -365 MB of GPU memory and -390 MB of PSS, no overlap between the arms, and the scrape is unaffected: 28/28/28 photos on the same place in both arms. This fetcher is the only one that lays out during a warm - WebPopularTimes' prewarm, WebDirections and WebStopDepartures never call measure/layout at all, and WebReviews has no warm. That is why every GL number in this app tracks this one view. Both fetchers now log `scraped N photos/reviews for <featureId>`, which is what makes a viewport change checkable against scrape QUALITY rather than only memory. A change that halves memory and quietly halves the gallery is a regression no memory metric would show. Shrinking the viewport was tried first and is NOT included. On the photo side 720 px held the count (28 -> 28) on the one place it was A/B'd, but the reviews side returns 0 reviews for every place tried, at 1200 AND at 720 - a pre-existing failure, unrelated to width - so that arm's quality metric was pinned at zero and could not fail. An unfalsifiable check is not evidence, and scrape geometry governs how much of a virtualized grid materializes, so both widths stay stock. Deferring the layout wins the same memory back without changing anything the scraper sees, which is the safer bet by construction rather than by sampling. Note GL mtrack at place-open is bimodal (~490 MB laid out and alive, ~71 MB not), so single readings there are worthless; the warm window is the stable thing to measure, and the numbers above are 3 runs per arm. Verified: :app:detekt and :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, standardDebug builds, installs and runs, photo gallery renders with the same photos.
alltechdev
force-pushed
the
perf/webview-and-native-purge
branch
from
July 21, 2026 05:33
2ef64c0 to
7dfc2ab
Compare
After a photo scrape the hidden WebView kept a fully rasterized Google Maps document until the 120 s reap - i.e. through the entire time the user is sitting on the place sheet looking at the photos. fetch()'s finally now navigates it to about:blank. Measured at place-open + 75 s, 3 runs: before GL mtrack ~497 MB TOTAL PSS ~950 MB after GL mtrack 64-70 MB TOTAL PSS 410-508 MB Photo counts unchanged. The WebView, renderer, sockets and cookies all stay alive, so the next place is no colder than before; only the document goes. Resizing is NOT the lever, and that was measured rather than assumed. Shrinking the view back to 0x0 after the scrape was tried first and reclaimed nothing: 494/496/497 MB against a 497/498 MB control. Chromium keeps the tiles it has already rasterized for a live document however small the view gets. The 1200x3200 viewport is 3.84 Mpx = 15 MB of pixels against a measured ~490 MB, so that number was never a viewport buffer - it is a whole composited layer tree against a tile budget. Document lifetime is what moves it. The about:blank navigation then broke the NEXT scrape, and this is the part worth reading. Its onPageFinished fires on the webViewClient the next fetch has just installed, which opens that fetch's load gate before the real page has committed, so the scraper injects into an empty document and returns nothing. onPageFinished now ignores about: URLs; the MAX_LOAD_MS fallback still covers a genuinely stuck load. That bug was only visible when opening a SECOND place: the same place scraped 33 photos when it was the first place opened and 0 when it was the second. A one-place test cannot see a WebView REUSE bug at all, and re-tapping the same place is served from the LRU cache without scraping, so it cannot see one either. Verified after the fix by opening two DIFFERENT places in one session, twice over: 28 then 52, and 28 then 33 against a 33 fresh-open baseline for that second place, no crashes, gallery renders. Verified: :app:detekt and :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, standardDebug builds, installs and runs, screenshots of both place sheets with their galleries.
…ve purge Every memory number in issue #83 and in this branch so far was standardDebug. The `staging` variant exists precisely to avoid that (initWith(release), R8, resources shrunk, non-debuggable, installs side by side as app.vela.staging) and nobody had used it. Production is substantially leaner than debug: state standardDebug standardStaging after a search ~460 MB ~279 MB place open 410-508 MB 335-392 MB after a severe trim - 140-146 MB Code bucket 101 MB 30-45 MB The Code gap is extracted dex and JIT profiles that do not exist in a release build, so roughly 55-70 MB of any debug reading is an artifact. Scrape verified unaffected by R8: 28 photos on staging, same as debug, and the @JavascriptInterface bridge survives minification (onResult is present in the minified dex). Two corrections, both to claims made earlier on this branch. FIRST: "a 442 MB arena holding just 46 MB live" was presented as though the gap were reclaimable. It is not. mallinfo's free figure is address space that scudo has already madvised away; scudo:primary PSS at that same moment was 67 MB. Measured directly by purging during active use with NO listener release - a RUNNING_MODERATE trim, which isSevere excludes - scudo:primary moved 67.1 MB to 64.5 MB. That is 2.6 MB. A periodic idle purge would be worthless and is not worth building; this note exists so nobody builds it. SECOND: the purge was reported as worth ~3 MB from a debug A/B. On production it is ~10 MB. A/B on staging, 3 runs per arm, comparing where scudo:primary SETTLES after a severe trim (the pre-trim value swings 147-382 MB run to run and is useless as a baseline): purge on 52.4/53.1/54.1 MB, purge off 54.4/58.6/76.7 MB. A single trim on production reclaims 133-328 MB, and it would have been easy to credit that to the purge - the first reading looked like 122 MB. Nearly all of it is the registered listeners releasing plus what the platform already does on trim. The control is what separated them. Docs only; no behaviour change.
The low-RAM ambient path fetched 8 of the 15 category terms. Both halves of that were wrong. It saved no peak memory. Peak is set by ambientFanout, a Semaphore(4), and every buffer - the response String, the stripped copy, the JsonElement DOM - is allocated INSIDE withPermit. At most 4 of those exist at once however many terms are queued behind them, so 15 to 8 changes how many WAVES the fan-out takes, not what is resident at the peak. The semaphore's own KDoc already said so: "Bounding to 4 caps the peak transient heap with the same final pool." The levers that do move the peak are the permit count and the response size, and the !7i pool halving is the one in use. It is untouched here. And its stated justification was false. It kept school and park on the grounds that only those two lack a second source while the ambient layer is up. Nothing has one then: VelaMapView sets poi_r1/poi_r7/poi_r20 to NONE wholesale on `if (navMode || ambientPois.isNotEmpty())`, not per category. So shopping, services, beauty salon, fast food, gym, bar and pharmacy lost their basemap fallback exactly as school and park would have. Parks at least keep a landuse polygon, so the green area survives without the pin; a gym, a bar or a pharmacy exists ONLY as a POI pin, which makes those the worse things to drop, not the safer ones. A constrained phone was quietly showing a different, poorer map. The observation behind the subset was real - a first 6-term attempt did lose every park and school pin, caught by an A/B screenshot. The generalisation drawn from it was not: that screenshot was evidence about the fan-out, not about school and park being special. Low-RAM devices now fetch the same terms as everyone else, so the category set is identical by construction rather than by sampling. The only remaining low-RAM difference in this path is the smaller !7i result pool, which trims deep-rank results per term without removing any category. Verified: :app:detekt and :core:detekt 0 smells, :core:test green, audit_deadcode.sh PASS, standardDebug builds and installs, and the app launches and renders ambient POIs with debug.vela.lowram forced true - park, shopping and restaurant pins present, no crash.
`heapClassMb in 1..127` had two defects, and both were invisible on any device the dev side owns. It excluded 128, which is the heap class OEMs hand out across 1 GB phones and the low end of 2 GB ones - exactly the class of device issue #83 was filed from. The phone this work was written for could plausibly have matched none of the predicate and received none of the work. And a probe that could not be read returns 0, which also falls outside 1..127, so an unknown device was silently routed down the memory-HUNGRY path. That is the wrong failure direction: landing on the low-RAM path costs a roomy phone about a second on its first mic tap and its first place open, while landing on the normal path can OOM a phone that had no headroom to begin with. The predicate now takes three signals, any one of which is enough: isLowRamDevice (canonical but only Go builds set it), total RAM at or under 2048 MB, and heap class at or under 128 MB. Total RAM is new here and is the signal that actually describes the device - heap class is a Dalvik knob an OEM can set to anything. An unreadable probe never counts as evidence of a roomy device, and two unreadable probes classify as constrained. MemoryInfo.totalMem reports what the OS can hand out rather than the marketing figure, so a nominal 2 GB phone reads about 1900 MB and lands inside the ceiling while a 3 GB phone reads about 2800 MB and does not. Device-confirmed: the M5 reads heapClassMb=256 totalRamMb=2878 and stays lowRam=false, so every measurement recorded in AGENTS.md was taken on the path that still ships to a roomy phone. The decision moved to LowRamMode.classify in :core for one reason: so it can be tested. A predicate that gates every memory adaptation in the app had no test, and both of its bugs were the kind only a device nobody owns would expose. LowRamModeTest pins the boundaries, including 128 itself, the inclusive total-RAM edge, unreadable probes, and the M5's own values. Negative control run, as AGENTS.md requires: restoring the original `isLowRamDevice || heapClassMb in 1..127` makes 4 of the 9 tests fail, including "heap class 128 is low-RAM, the boundary the first version excluded" and "both probes unreadable is treated as constrained, not roomy". The tests fail on the bug they were written for. Verified: :app:detekt and :core:detekt 0 smells, :core:test green (9/9 in the new file), audit_deadcode.sh PASS, standardDebug builds, installs and launches, and the M5 logs lowRam=false unchanged.
Two gaps closed, both methodology, both with numbers that did not exist before.
FIRST: the low-RAM branches had never run on a minified build. debug.vela.lowram
is BuildConfig.DEBUG-gated, so it is inert on staging and release, and every
low-RAM measurement so far came from a debug build with the flag forced.
Magisk's resetprop closes that:
adb shell su -c "resetprop dalvik.vm.heapgrowthlimit 96m"
ActivityManager.staticGetMemoryClass() reads that property per call, so the app
sees the new heap class immediately and LowRamMode.classify runs for real
(forced=no). Restore it afterwards - it sets the heap growth limit for every app
started after.
Verifying on staging needs a behavioural probe rather than a log, because
Timber.plant(DebugTree) is also DEBUG-gated and MemoryPressure never reaches
logcat there. The renderer count works: low-RAM skips the speculative WebView
warm, so `ps -A | grep -c sandboxed_process` is 0 after a search on that path and
1 on the normal one. Across three pairs that signal was 0/0/0 against 1/1/1,
perfectly separated where PSS was not.
Measured that way, the low-RAM path is worth about 148 MB on the production
build: 166/169/173 MB against 287/372/295 MB, no crashes, which also shows R8
does not break those branches.
SECOND: nothing had ever put the app under real memory pressure. A hog that
allocates once measures nothing - this device has 1.6 GB of zram, so the pages
are simply compressed and MemAvailable goes UP. The first attempt "applied"
1500 MB and freed 148 MB. Re-touching every page in a loop denies them to the
swapper, and then it bites: MemAvailable fell to ~130 MB and lmkd started killing
on "direct reclaim and thrashing".
What that showed is worth recording, because it cuts against the design this
branch inherited. Under real pressure staging received ZERO onTrimMemory
callbacks and was killed 20 s in, at oom_score_adj 0, reason "device is not
responding". The debug build at a gentler 1.6 GB got EXACTLY ONE level=15,
released and purged in 1 ms, and was killed 8 s later. lmkd kills on thrash-driven
unresponsiveness before AMS gets round to asking anyone to release anything.
So a release that only happens on a trim mostly does not happen. Proactive
reclaim - the idle reapers, not warming what will not be used, not holding a
scraped document - is what actually protects a constrained phone. That is an
argument for the changes on this branch over the trim fan-out alone, and it is
recorded here rather than claimed in a commit subject.
Also noted: do not push past ~1.6 GB on this hardware. At 1.9 GB the launcher
enters a kill loop and the app dies before MemoryPressure.init runs, so the test
stops discriminating between good and bad memory behaviour. A continuously
rewritten 1.9 GB is a pathological workload, not a small phone.
Docs only; no behaviour change.
Startup on this app is GC-bound, not compilation-bound. An atrace of a cold start
on the staging build attributes seconds to GC phases (CopyingPhase, NativeAlloc
concurrent copying GC, MarkingPhase), and forcing full AOT compilation made cold
start WORSE, 828 ms against 775 ms - so a baseline profile would buy nothing and
the thing worth cutting is allocation.
FlockCameras.loadFrom was the largest single allocator at startup. For the
bundled dataset (127,770 rows as shipped today) it created roughly 400,000
objects that were garbage within milliseconds:
- 255,540 boxed java.lang.Double, from two ArrayList<Double> accumulators that
were copied into DoubleArrays and discarded
- one boxed Integer per camera, 127,770 of them, from .add(i) into
MutableList<Int>
- per occupied cell an ArrayList, its Object[] backing, a boxed Long key and a
HashMap.Node - 14,273 cells, so ~57,000 more
The coordinates now accumulate into primitive DoubleArrays that double on
demand, and the 0.1 degree bucket index is a flat CSR triple - sorted unique
LongArray of cell keys, IntArray of start offsets, IntArray of row indices -
built with a primitive sort and two counting passes. Five arrays instead of
~200,000 objects. Lookup is a binary search over ~14,000 keys, and the two call
sites move from `grid[key(r, c)]?.let { for (i in bucket) ... }` to an inline
forEachInCell, which also drops the iterator and the unboxing per step.
Proven equivalent on the real dataset rather than argued: a temporary check
rebuilt the old HashMap index alongside the new one during a real load on device
and compared every bucket. rows=127770 cells=14273 oldCells=14273 mismatches=0.
The check was removed again after it passed; this is the only kind of evidence
worth having for an index rewrite, since the data is the thing that finds the
edge cases.
On the GC metric it was written for, mean GC time across a cold start fell from
3408 ms to 2340 ms (n=3 per arm). Stated honestly, that is directional, not
conclusive: the arms overlap (2355-4644 before, 1641-3144 after) and three runs
cannot separate them. Cold start wall time is worse still as a metric here - it
swings 739-1552 ms on this device - which is why GC slices were measured instead.
The allocation reduction itself is not in doubt; the size of its effect is.
Behaviour is unchanged by construction: same rows, same cells, same buckets, same
camera list. The Flock layer is ON by default (a deliberate 2026-07-13 call), so
deferring this work instead was not an option - it had to get cheaper, not later.
Verified: :app:detekt 0 smells, :core:test green, audit_deadcode.sh PASS,
standardStaging builds, installs and runs, map renders, 0 crashes.
…nfixed item Four measurements worth not repeating, and one large item this branch does NOT fix. MapScreen is too big for ART to compile. On the shipping build ART logs "Method exceeds compiler instruction limit: 19621 in void i2.r1.f(...)", which the R8 mapping resolves to MapScreenKt.MapScreen - so the composable that runs on every recomposition of the main screen is never compiled and runs interpreted. Its body spans roughly lines 192-2172. This is the largest known performance item in the app and it is still open. A trial extraction of the biggest block (lines 1828-2048, 221 lines) was done and reverted, and the recipe is recorded so the next attempt is not exploratory: the extracted function needs a BoxScope receiver, it captures 23 named values, and six of those are `var ... by remember` that the block WRITES - pass the MutableState and re-delegate with `by` so the body stays byte-identical. Passing them by value is a compile error rather than a silent break, which is what makes the refactor tractable at all. It should be done one block at a time, re-checking the logcat instruction count after each. Startup is GC-bound, not compilation-bound. Forcing full AOT made cold start WORSE, 828 ms against 775 ms, which is an upper bound on anything a baseline profile could buy - so that idea is closed rather than pending. An atrace instead attributes seconds to GC phases, which is what motivated the FlockCameras index rewrite in the previous commit. Measuring startup needs the dexopt state controlled or it measures nothing: `adb install -r` resets it, and comparing a fresh-install arm against a warmed baseline once "showed" that REMOVING work made startup slower. Cold start swings 739-1552 ms on this device even when matched, so anything smaller than a few hundred ms needs a lower-variance metric. And dumpsys gfxinfo does not measure this app's map at all - MapLibre renders through its own GL context, so HWUI stats cover only the Compose chrome. A D-pad drive produced 60 frames at 0% jank and a swipe drive 11 frames; neither could have detected a regression in either direction. Docs only; no behaviour change.
The previous note said MapScreen exceeds ART's compiler limit and described how to extract a block out of it. Following that advice naively does not work, and this is measured rather than reasoned. The largest block (lines 1828-2048, the idle-map overlay cluster, 221 lines) was fully extracted into BoxScope.MapIdleOverlays with all 21 captures passed and the six `var ... by remember` states passed as MutableState and re-delegated so the body stayed byte-identical. It compiled, installed, ran and did not crash. And MapScreen went from 19,621 to 20,351 instructions - the wrong direction. The reason is Compose's calling convention: it emits $changed/$changed1 bitmask plumbing per parameter at the call site, and for 21 parameters that costs more than a 221-line body removes. The change was reverted; the tree is back to 19,621 and green. So the rule is the opposite of the intuitive one: extract blocks with FEW captures, not the biggest blocks. Count the captures first - comment the block out, compile, and read the unresolved references, which is the exact list for one build. A 100-line block taking 4 parameters beats a 220-line block taking 21. Recomputing composable-local values inside the extracted function (LocalContext.current, stringResource, isAppInDarkTheme, VelaSoftkeys.isActive) instead of passing them drops the count further at no behavioural cost. Docs only; no behaviour change. MapScreen is still uncompiled and still the largest known performance item.
The previous note said extraction makes MapScreen worse and blamed Compose's per-parameter $changed plumbing, concluding that blocks with FEW captures should be extracted instead. That follow-up hypothesis was tested and is also wrong. block extracted lines caps lines/cap MapScreen after (baseline) - - - 19,621 1828-2048 idle overlays 221 23 9.6 20,351 (+730) 991-1104 dpad overlay 114 9 12.7 21,638 (+2,017) The second block was picked precisely because its lines-per-capture ratio was far better, which the parameter-plumbing theory predicted would win. It came out worse than the first, and worse in absolute terms. Both compiled, installed and ran with no crashes, so this is purely a code-size result; both were reverted and the tree is back at 19,621. Whatever dominates the instruction count, adding a composable call layer costs more than the body it removes. Extraction is therefore closed as an approach: MapScreen cannot be brought under the ~10,000 limit by pulling blocks out of it, and a third attempt at the same idea is not worth anyone's build time. The mechanics recorded earlier (BoxScope receiver, passing MutableState and re-delegating with `by` to keep the body byte-identical) are correct and worth keeping - it is the strategy they served that does not hold. MapScreen remains uncompiled and remains the largest known performance item. A different hypothesis is needed, and it should be measured in one build before any refactoring work is done. Docs only; no behaviour change.
MapScreen exceeding ART's compiler limit looked like the largest performance item in the app, and two extraction attempts were made and reverted chasing it. Nobody had checked whether it actually costs frames. It does not. An atrace across a cold start plus a D-pad drive on the staging build: Choreographer#doFrame 3,860 ms over 430 frames = 9.0 ms/frame measure/layout/draw 1,524 ms = 3.5 ms/frame GC 2,165 ms inflate 16 ms Against a 16.7 ms budget at 60 Hz there is no frame-budget problem on this device. A method being uncompiled only matters if it runs hot, and at 9 ms/frame this one is not hurting. That explains why both extractions changed the instruction count and nothing a user could feel: they were aimed at something that was not costing anything. This measurement should have been taken first, and the note now says so. It also redirects the effort: GC is the largest remaining cost in the trace, which is what the FlockCameras index rewrite targeted, and allocation is where further work belongs rather than composable restructuring. MapScreen stays recorded as the largest code-size anomaly, but is explicitly demoted from "largest performance item" to "do not spend effort here without first showing it costs frames". Docs only; no behaviour change.
…s, which are fine This session measured frame time, cold start, GC and instruction counts, and the honest summary of all of it is that the app is inside its frame budget with room to spare: 9.0 ms per frame against 16.7 ms. Two MapScreen refactors were attempted and reverted chasing an instruction count that turned out not to cost frames at all. The axis that was never measured until now is the one a user actually experiences. On staging, tapping a place and waiting for a complete gallery is 28.8 seconds (35 photos). That is not a stall - the photo scraper polls up to 58 ticks at 500 ms behind a 40 s timeout, and reviews sit behind 45 s - it is the designed shape of scraping a rendered Google page. But it is what "the whole app is a bit slow" almost certainly means, and it dwarfs anything measurable in frames or startup. So the note now says to start there: how long until the user sees the thing they asked for - search results, ambient pins, the gallery, reviews - rather than gfxinfo or cold start, neither of which showed a problem worth fixing. It also records the clearest unpulled lead. nearbyPlaces fans 15 category terms out 4-at-a-time and ends with awaitAll().flatten(), so every ambient pin appears at once after the slowest term, about four network waves in. Streaming each term as it lands would put the first pins on screen roughly 4x sooner for identical total work. That changes what the user sees while loading, so it wants a before/after measurement rather than a drive-by edit. Docs only; no behaviour change.
The previous note flagged nearbyPlaces' awaitAll as the clearest latency lead: 15 terms 4-at-a-time, every ambient pin appearing at once after the slowest one, so streaming each term would show first pins ~4x sooner. That is still the right lead, but attempting it quickly would reintroduce a bug this codebase already fixed, and the note now says so. nearbyPlaces post-processes the whole merged pool with the SLIM-FLAVOR HEAL. For the first ~3 s of a session Google serves per-place blocks with the review count absent, which zeroes ambientProminence and - quoting the existing comment - "silently broke everything keyed on it: prominence ranking, dot sizing, label tiers - all flat". The heal detects that flavour across the pool and refetches. Painting each term as it lands would put pins on screen before the heal can run, which is precisely that flat-ranking regression. There is also no onPartial on MapDataSource.nearbyPlaces today - photos and reviews have one, ambient does not - so the interface, the ViewModel call site at MapViewModel.kt:3659, the heal, rankAmbientPlaces and the take-N cap have to be designed together rather than patched at the fan-out. One thing does already hold: collision priority is stable across uploads because it is keyed on prominence rather than list index (upstream c35eea33), so repeated partial uploads will not reshuffle icon placement. That removes one of the two obvious hazards, leaving the heal as the real one. Docs only; no behaviour change.
Same resolution stance as the #85 branch merge (437c24e), applied to this branch's rewrites: - WhisperRecognizer: keep this branch's lease/ReentrantLock release architecture (the use-after-free fix) and port main's multi-engine loader (engine+lang key, per-engine strikes/quarantine, three model configs) into ensureRecognizerLocked. loadedLang becomes loadedKey. - MapViewModel: keep main's per-engine flows; release(wait = true) moves into deleteAsrEngine so Remove still frees the native model first. - app/build.gradle.kts: keep main's ARM-only ndk abiFilters and ONNX-only packaging excludes; keep this branch's cmake abiFilters for libvelamem.
…, add a low-memory ASR engine The review round on this PR surfaced 12 verified findings; all are fixed: - WhisperRecognizer: the engine-switch rebuild now retires a superseded recognizer while leases are outstanding instead of freeing it under a running decode; lease acquisition is cancellation-safe (a leaked lease permanently disabled every release path); REAP_IDLE_MS is RAM-scaled (120 s low-RAM / 600 s roomy) so roomy phones keep instant mic taps. - PiperSynth: the ASR two-strike native-crash quarantine now wraps the TTS load per voice (issue #95's crash-loop class), and the trim path releases with interrupt = false so a speaking nav prompt is never cut off mid-word. - Web fetchers: all five now decline a merely-severe trim reap while a fetch is in flight and drain pending deferreds on a forced (critical) teardown - no more 20-45 s mutex-holding hangs or emptied galleries; reap bookkeeping is main-thread-confined everywhere. - MapViewModel.deleteAsrEngine runs its lock wait + native free + 154 MB recursive delete on Dispatchers.IO with an optimistic row hide. - FlockCameras publishes the CSR index as one immutable generation behind a single volatile reference - the refresh hot-swap can no longer tear against a viewport scan (AIOOBE). Unit-tested. - GoogleMapsDataSource: the low-RAM !7i30 pool halving is reverted - it silently removed POI ranks 31-60 with no basemap fallback. armv7 (issue #95): the vendored sherpa-onnx AAR moves 1.13.3 -> 1.13.4, whose bundled onnxruntime (1.27.0, from 1.24.3) fixes the BUS_ADRALN unaligned-read SIGBUS that crashed every model load on 32-bit ARM. Device-verified both directions on an M5 forced to --abi armeabi-v7a: TTS (the exact #95 voice) and ASR load, run, release and survive trims in a 32-bit process. CI fetches the new AAR; do-not-downgrade notes in the build file and AGENTS.md. New: AsrEngine.ZIPFORMER_SMALL (28 MB English transducer) as the low-memory candidate for feature phones - NeMo Conformer CTC was tried and rejected at ~760 MB-1.2 GB resident. Zipformer emits ALL-CAPS spoken-form text, so SpeechText.cleanSearchTranscript now lowercases shouting and runs spokenNumbersToDigits, unit-tested inverse text normalization ("ONE TWENTY THREE MAIN STREET" -> "123 main street", ordinal streets, non-English passthrough). detekt 0 findings; 49 unit tests green (14 new).
The translation-completeness gate caught settings_asr_langs_zipformer existing only in English; each locale follows its own moonshine-caption phrasing.
Resident size was never the only bar: Zipformer small is a 2023 librispeech (audiobook-domain) model, and a maps app lives on the proper nouns that domain mishears - no post-processing fixes that. With the idle reaper making Whisper's ~214 MB hold transient rather than session-long, the low-memory engine stopped paying for its accuracy cost. The engine entry, config branch, picker caption and its 14 translations go; the measurement lessons (NeMo ballooning, Moonshine's four sessions, the reap-delta isolation method) stay in AGENTS.md so the ground isn't re-trodden. spokenNumbersToDigits + the ALL-CAPS unshout stay in cleanSearchTranscript with their tests: they are a no-op on the three digit-writing engines and insurance for any future word-form one.
The 1.13.4 AAR is 48.8 MB (1.13.3 was 57 MB) and the >50 MB check failed the successfully-downloaded upgrade - the first failure looked transient because the earlier run in the same hour passed on a cached step ordering. Threshold to >40 MB and a note that this is a truncation guard to resize on bumps, not a version pin.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #83. Fixes #95. Combines and supersedes #85.
One PR, three layers of work, all device-verified on the M5 (2.9 GB, Android 13) in both 64-bit and 32-bit processes:
malloptpurge, deferred WebView layout, and low-RAM adaptations. Heavy states drop by half or more (measurements in the appendices below).libonnxruntime.soon the TCL Flip 2) turned out to be a runtime bug that killed every model load on armv7, TTS and ASR alike. Fixed at the runtime, plus crash-loop insurance, plus a new low-memory ASR engine for exactly these phones.Voice on 32-bit ARM (issue #95)
The #95 tombstone (
BUS_ADRALN, piper-tts thread) reproduced on the M5 forced into a 32-bit process (adb install --abi armeabi-v7a) — and reproduced identically for the ASR load. The vendored sherpa-onnx 1.13.3 AAR bundles onnxruntime 1.24.3, whose v7a build does unaligned reads that 32-bit ARM turns into an uncatchable native abort. These phones have no system speech engine — the downloaded models are their only voice — so gating was not an option.Fix:
sherpa-onnx-1.13.4.aar(onnxruntime 1.27.0). Hosted on thetts-runtimerelease, CI fetch + v7a-presence check updated, do-not-downgrade notes in the build file and AGENTS.md. Device-verified both directions, final build, no debug overrides, 32-bit process:PiperSynth: loaded en_US-hfc_female-medium(the exact Crash on launch after downloading voice model on 32-bit ARM device #95 voice, load + warm synthesis), Whisper loads, critical trim →recognizer released+ native purge, PSS 641→271 MB, process alive throughout. arm64 regression-checked after the bump.Crash-loop insurance: the ASR loads already had a two-strike per-engine quarantine; the Piper TTS load now has the same (
piper_load_strikes_<voiceId>/piper_model_bad_<voiceId>, cleared by a fresh download). The #95 failure class — a natively-faulting model at startup — can never brick the app again on any chipset: worst case two crashes, then system TTS and a re-download.The low-memory ASR engine hunt: measured, then deliberately dropped
"Small download" ≠ "small resident" (32-bit M5, same-protocol launch deltas):
Zipformer small was built, wired, measured — and then removed before merge. Resident size is not the only bar: it is a 2023 librispeech (audiobook-domain) model, and a maps app lives on exactly the proper nouns that domain mishears; no post-processing fixes that. The decisive change is elsewhere in this PR: the idle reaper makes Whisper's ~214 MB hold transient (load at the mic tap, reaped 120 s later on low-RAM devices), which is what actually makes the multilingual default viable on small phones. The measurement lessons are recorded in AGENTS.md so the ground isn't re-trodden.
Kept from that work:
SpeechText.cleanSearchTranscriptnow lowercases ALL-CAPS transcripts and runsspokenNumbersToDigits— unit-tested inverse text normalization ("ONE TWENTY THREE MAIN STREET" → "123 main street", "forty second street" → "42nd street", juxtaposition + place-value + ordinals, lone "first"–"ninth" deliberately left as words, non-English untouched). A no-op on the three digit-writing engines, insurance for any future word-form one. 8 new tests.The 12 review findings, all fixed
ensureRecognizerLockedfreed the superseded recognizer while a listen could still be decoding inside it → superseded models park in aretiredlist, drained underloadLockwhen the lease count hits zero.withContext { acquireRecognizer() }could take the lease then throwCancellationExceptioninstead of returning it, permanently disabling every release path → aleasedflag set inside the block keys thefinally.reapNow(force)drainspendingwhenever it destroys — no 20–45 s mutex-holding hangs.deleteAsrEnginemain-thread block: lock wait + native free + up-to-154 MB delete moved toDispatchers.IO, optimistic row hide.@Volatilereference, snapshotted once per query — the weekly-refresh hot-swap can no longer throw AIOOBE under a viewport scan. 6 new unit tests.release(interrupt = false)— the serial worker frees the model after the current utterance instead of cutting it off mid-word.!7i60everywhere — ranks 31–60 were vanishing with no basemap fallback; peak memory is governed by the Semaphore(4) fan-out bound, not the per-term pool.finallyre-arms it moments later); only CRITICAL forces teardown + drain.REAP_IDLE_MSis RAM-scaled — 120 s on low-RAM, 600 s on roomy devices.onMainconfinement; reap bookkeeping is main-thread-only by construction.Verification
detekt 0 findings (
:app+:core); 49 unit tests green (14 new: FlockCameras index + ITN);standardDebugbuilt, installed, and exercised on the M5 in both 32-bit and 64-bit processes — launches, TTS + ASR model loads, critical-trim release + native purge, idle reap, UI verified intact by screenshot (full POI categories back, including the park/school pins). Stated plainly rather than implied: the mid-scrape trim-decline path is verified by tests + construction, not against a live scrape; no real-microphone decode ran on v7a (the identical model + runtime pair decodes correctly on the host); hotword biasing is a follow-up.AGENTS.md gains the rules this work produced: the lease covers every free path including engine switches; lease acquisition must be cancellation-safe; trim reaps decline mid-fetch below CRITICAL; the AAR is pinned ≥ 1.13.4 for armv7 (test any bump on a 32-bit install); a model's file size says nothing about its resident cost — isolate it via the idle-reap delta before adopting.
Appendix: original #86 description (WebView/native purge round, with its measurements)
Stacks on #85. Seven changes, all device-measured on the M5 (2.9 GB, Android 13,
standardDebug).1. The ASR reaper could free the speech model under a running decode
This is the one to review first. The
inFlightguard added with the idle reaper in #85 does not close the window its own KDoc describes.release()reads the counter before takingloadLock, andensureRecognizer()hands the native pointer out on a lock-free fast path before taking the lock at all, so the two never order against each other:That is a SIGSEGV inside
libsherpa-onnx-jni, not an exception - therunCatchingaround the decode cannot catch a native abort, so the process dies. AnAtomicIntegerdoes not make a check-then-act atomic.The window is not narrow: any thread holding
loadLockfor the ~1 s native load parksrelease()between its check and its free for that whole time, andwarmUp()takes that lock at startup on every non-low-RAM device.Fixed with a lease mutated only under
loadLock:acquireRecognizer()loads and leases under one lock,release()checks and frees under that same lock. The lock-free fast path is gone.release()also becomes non-blocking (tryLockon aReentrantLock), because it runs on the main thread fromonTrimMemoryand blocking the UI thread for a full model load to reclaim memory is a bad trade;Remove modelpasseswait = true.The race is real, not theoretical. Hammering
am send-trim-memory <pid> RUNNING_CRITICALacross startup loggedrelease skipped, model load in progress5 times in one run - 5 trims landing while the load held the lock, each of which the old code would have used to block the main thread with the check-then-act live. Across that plus a 6-round force-stop/trim-storm: 0 crashes, 0 SIGSEGV, process alive every round. Release still works (scudo:secondary110,564 -> 8,031 KB,recognizer releasedlogged), the model reloads cleanly afterwards (108,381 KB) and is not quarantined.PiperSynthalready gets this right by serializing through its single worker thread; only the ASR path was exposed.2. One search pinned a Chromium renderer for the whole session
Android runs the WebView renderer out of process, so #85 could not see it. After one search: app 386 MB,
sandboxed_process0327 MB, webview service/apk 58 MB. The app-side half isGL mtrackfrom the offscreen layouts: 26 MB with no WebView alive, 437 MB with the two scraper views up.Neither speculative warm was bounded.
WebPhotoFetcherhad no idle reaper at all;WebPopularTimesFetcher.prewarm()created a view and never calledscheduleReap()(onlyfetch()did).MapViewModelwarms both on every search.Both had to be fixed - reaping only the photo fetcher left the renderer alive at ~200 MB because the popular-times view still held it. The renderer is shared by every WebView in the process.
Measured, no trim anywhere in the run:
~390 MB back to the app plus a ~305 MB process shut down. Rebuild verified: a later search respawns it, GL mtrack returns to 431 MB, no crash.
A speculative warm gets a longer window than a real fetch (
WARM_REAP_IDLE_MS300 s vsREAP_IDLE_MS120 s) - the warm exists so the first place tap skips the cold start, and 120 s would expire during an ordinary browse and waste it. Bounded, not short, is the point.Also fixes two things reachable from #85's own trim hook:
reapNow()now drainspendinglikerendererGone()already did (a reap mid-fetch parked the fetch indeferred.await()for the full 40 s timeout while holding the fetcher's Mutex), and reap bookkeeping moved onto the main thread, sincereapwas written from two threads and scheduling is a read-modify-write.3. The photo scraper laid out its WebView during a speculative warm, at 390 MB
WebPhotoFetchersized its view insideensureWebView()- i.e. at construction - andwarm()goes throughensureWebView(). So a search built a full 1200x3200 composited surface overmaps?hl=en, a page with nothing scrapeable on it, and held it for the whole 300 s warm window, on a phone whose screen is 480x640.Sizing moved into
sizeForScrape(wv), called immediately beforeloadUrlinfetch(), so the?cid=page's first layout is already at scrape geometry. The size is unchanged; only when it is applied changes.Matched A/B, same harness, 3 runs per arm, search then browse with no place opened:
-365 MB of GPU memory and -390 MB of PSS, no overlap between arms, and the scrape is unaffected: 28/28/28 photos on the same place in both arms.
This fetcher is the only one that lays out during a warm -
WebPopularTimesFetcher.prewarm,WebDirectionsFetcherandWebStopDeparturesFetchernever call measure/layout at all, andWebReviewsFetcherhas no warm. That is why every GL number in this app tracks this one view.Shrinking the viewport was tried first and is deliberately NOT included. On the photo side 720 px held the count (28 -> 28) on the one place it was A/B'd, but the reviews side returns 0 reviews for every place tried, at 1200 and at 720 - a pre-existing failure unrelated to width - so that arm's quality metric was pinned at zero and could not fail. An unfalsifiable check is not evidence, and scrape geometry governs how much of a virtualized grid materializes, so both widths stay stock. Deferring the layout wins the same memory back without changing anything the scraper sees: safe by construction rather than by sampling.
Both fetchers now log
scraped N photos/reviews for <featureId>, which is what makes any future viewport change checkable against scrape quality rather than only memory.4. The scraped page was held for two minutes after the scrape ended
After a photo scrape the hidden WebView kept a fully rasterized Google Maps document until the 120 s reap - the whole time the user sits on the place sheet looking at the photos.
fetch()'sfinallynow navigates it toabout:blank.Measured at place-open + 75 s, 3 runs:
Photo counts unchanged. The WebView, renderer, sockets and cookies stay alive, so the next place is no colder; only the document goes.
Resizing is not the lever, and that was measured rather than assumed. Shrinking the view to 0x0 after the scrape was tried first and reclaimed nothing: 494/496/497 MB against a 497/498 MB control. Chromium keeps tiles it has rasterized for a live document however small the view gets. The 1200x3200 viewport is 3.84 Mpx = 15 MB of pixels against a measured ~490 MB, so that figure was never a viewport buffer - it is a whole composited layer tree against a tile budget. Document lifetime is what moves it.
The bug this introduced, and why it nearly shipped
The
about:blanknavigation broke the next scrape. ItsonPageFinishedfires on thewebViewClientthe next fetch has just installed, opening that fetch's load gate before the real page commits, so the scraper injects into an empty document and returns nothing.onPageFinishednow ignoresabout:URLs; theMAX_LOAD_MSfallback still covers a genuinely stuck load.It was only visible when opening a second place: the same place scraped 33 photos as the first place opened and 0 as the second. A one-place test cannot see a WebView reuse bug, and re-tapping the same place is served from the LRU cache without scraping, so it cannot see one either. Verified after the fix by opening two different places in one session, twice: 28 then 52, and 28 then 33 against that place's 33 fresh-open baseline.
5. A release only returns memory to scudo, not to the kernel (worth ~10 MB)
#85 gave every holder a
release(), but those pages stay on the allocator's free lists where PSS still counts them.mallopt()is reachable only from C, so this adds the app's first native module:app/src/main/cpp/velamem.cpp, 4 KB (arm64) / 2.7 KB (armeabi-v7a), built for those two ABIs only.A/B'd on one binary, both arms confirmed in logcat to behave differently before trusting either. On the production (
staging) build, 3 runs per arm, comparing wherescudo:primarySETTLES after a severe trim (the pre-trim value swings 147-382 MB run to run and is useless as a baseline):scudo:primaryafter trim~10 MB on production (a debug A/B had shown ~3 MB).
M_PURGE_ALLis API 34+, so on Android 13 it returns 0 and falls back toM_PURGE; the loggedmode=says which took.Two corrections to earlier claims in this branch, both since fixed in AGENTS.md:
mallinfo's free figure is address space scudo has already madvised away, andscudo:primaryPSS at that moment was 67 MB. Purging during active use with no listener release moved it 67.1 -> 64.5 MB, i.e. 2.6 MB. A periodic idle purge would be worthless; that is now written down so nobody builds it.6. Low-RAM phones were losing whole POI categories, for no memory saving
#85's low-RAM path fetched 8 of the 15 ambient category terms. Both halves of that were wrong.
It saved no peak memory. Peak is set by
ambientFanout, aSemaphore(4), and every buffer (responseString, stripped copy,JsonElementDOM) is allocated insidewithPermit. At most 4 exist at once however many terms queue behind them, so 15 -> 8 changes the number of waves, not what is resident at the peak. The semaphore's own KDoc already said it: "Bounding to 4 caps the peak transient heap with the same final pool." The levers that do move the peak are the permit count and the response size; the!7ipool halving is the one in use and is untouched here.Its justification was false. It kept
schoolandparkon the grounds that only those lack a second source while the ambient layer is up. Nothing has one then -VelaMapViewsetspoi_r1/poi_r7/poi_r20toNONEwholesale onif (navMode || ambientPois.isNotEmpty()), not per category. So shopping, services, beauty salon, fast food, gym, bar and pharmacy lost their basemap fallback exactly as school and park would have. Parks at least keep a landuse polygon so the green area survives without the pin; a gym, a bar or a pharmacy exists only as a pin, making those the worse things to drop, not the safer ones. A constrained phone was quietly showing a poorer map.The observation behind the subset was real - a first 6-term attempt did lose every park and school pin, caught by an A/B screenshot. The generalisation drawn from it was not: that screenshot was evidence about the fan-out, not about school and park being special.
Low-RAM devices now fetch the same terms as everyone else, so the category set is identical by construction rather than by sampling. The only remaining low-RAM difference in this path is the smaller
!7iresult pool, which trims deep-rank results per term without removing any category.7. The low-RAM check could miss the phone it was written for
heapClassMb in 1..127had two defects, both invisible on any device the dev side owns.It excluded 128 - the heap class OEMs hand out across 1 GB phones and the low end of 2 GB ones, exactly the class of device #83 was filed from. The phone this work targets could plausibly have matched none of the predicate and received none of the work.
And an unreadable probe returns 0, which also falls outside
1..127, so an unknown device was silently routed down the memory-hungry path. That is the wrong failure direction: the low-RAM path costs a roomy phone about a second on its first mic tap and first place open; the normal path can OOM a phone with no headroom.The predicate now takes three signals, any one sufficient:
isLowRamDevice, total RAM <= 2048 MB, heap class <= 128 MB. Total RAM is new and is the signal that actually describes the device - heap class is a Dalvik knob an OEM can set to anything. An unreadable probe never counts as evidence of roominess.MemoryInfo.totalMemreports what the OS can hand out rather than the marketing figure, so a nominal 2 GB phone reads ~1900 MB and lands inside the ceiling while a 3 GB phone reads ~2800 MB and does not. Device-confirmed: the M5 readsheapClassMb=256 totalRamMb=2878and stayslowRam=false, so every measurement in this PR was taken on the path that still ships to a roomy phone.It now has tests, and they were proven to fail on the bug
The decision moved to
LowRamMode.classifyin:corefor one reason: so it can be tested. A predicate gating every memory adaptation in the app had no test, and both its bugs were the kind only a device nobody owns would expose.Negative control, as AGENTS.md requires: restoring the original
isLowRamDevice || heapClassMb in 1..127makes 4 of the 9 tests fail, including "heap class 128 is low-RAM, the boundary the first version excluded" and "both probes unreadable is treated as constrained, not roomy". The tests fail on the bug they were written for.Production numbers, and a warning about the debug ones
Every number in #85 and in the sections above was
standardDebug. Thestagingvariant (initWith(release), R8-minified, non-debuggable, installs side by side asapp.vela.staging) is the production profile, and nobody had used it. Production is substantially leaner:CodebucketThe
Codegap is extracted dex and JIT profiles that do not exist in a release build, so ~55-70 MB of any debug reading is an artifact. Scrape verified unaffected by R8 (28 photos on staging, same as debug; the@JavascriptInterfacebridge survives minification).Verified on a production build, and stressed under real pressure
Two gaps that existed before this PR, both now closed with numbers.
The low-RAM path had never run on a minified build
debug.vela.lowramisBuildConfig.DEBUG-gated, so it is inert onstaging/release- every low-RAM measurement in #85 and above came from a debug build with the flag forced. Magisk'sresetpropcloses that, becauseActivityManager.staticGetMemoryClass()reads the property per call, soLowRamMode.classifyruns for real (forced=no):Verifying needs a behavioural probe, not a log:
Timber.plant(DebugTree)is also DEBUG-gated, soMemoryPressurenever reaches logcat on staging. The renderer count works - low-RAM skips the speculative WebView warm:sandboxed_processcountThe low-RAM path is worth about 148 MB on the production build, the behavioural signal is perfectly separated where PSS is not, and 0 crashes shows R8 does not break those branches.
Trims are not a reliable defence, which argues for this PR's approach
Nothing had ever put the app under real pressure. A hog that allocates once measures nothing: this device has 1.6 GB of zram, so the pages are compressed and
MemAvailablegoes up - the first attempt "applied" 1500 MB and freed 148 MB. Re-touching every page in a loop denies them to the swapper, and then it bites (MemAvailable~130 MB, lmkd killing on "direct reclaim and thrashing").What that showed:
oom_score_adj 0, "device is not responding"level=15)lmkd kills on thrash-driven unresponsiveness before AMS gets round to asking anyone to release. So a release that only happens on a trim mostly does not happen. Proactive reclaim - the idle reapers, not warming what will not be used, not holding a scraped document after the scrape - is what actually protects a constrained phone. That is the case for the changes here over the trim fan-out alone.
Stated plainly: the app still dies under that pressure. This is not a survival claim. Past ~1.6 GB the test stops discriminating anyway - at 1.9 GB the launcher enters a kill loop and the app dies before
MemoryPressure.initruns, because a continuously rewritten 1.9 GB is a pathological workload, not a small phone.Corrections to #85
MemoryPressureKDoc told readers to runsetprop debug.vela.lowram ""for real detection, which AGENTS.md itself calls a shell syntax error.falsedoes not restore detection either - it forces the normal path. Clearing needs an unparseable value, so both now saynone, verified on device (forced=no).UI_HIDDEN(20), neverBACKGROUND(40).isSeverestarts at 40, so on an ordinary HOME press Use a lot less memory, and give it back when the system asks #85 releases nothing (measured: native heap 64000 -> 63912 KB, noise). Defensible, but Use a lot less memory, and give it back when the system asks #85 reads as though backgrounding reclaims memory. The purge therefore triggers from level 10 up, wider thanisSevere.Still open, not fixed here
Unrelated to this PR: the reviews scrape returns 0 for every place tried on the test device, at stock settings and at every viewport width tried. The retry logic in
MapViewModelimplies it is meant to work, so that looks like a live bug worth its own investigation. It also means any future reviews-side A/B is unfalsifiable until it is fixed.Verification
:app:detekt+:core:detekt0 smells,:core:testgreen,audit_deadcode.shPASS,audit_static.shno new violations (the four reported are pre-existing, in files untouched here), both flavors build,standardDebuginstalls and runs with noUnsatisfiedLinkError, screenshots of the map after each change.AGENTS.md gains the rules this work needed: a lease must be taken under the same lock that frees;
release()must not block the main thread; always total the out-of-process WebView processes when measuring; both-warms-or-neither; drainpendingon reap; verify a reap by its LOG not by process death (the first attempt here was unfalsifiable - the renderer vanished at t+60 s to a reallevel=15/40trim, not the reaper); the one-binary A/B rule; and theasr_model_badquarantine trap, which makeswarmUp()a silent no-op so a benchmark measures the model-absent case without saying so.Appendix: original #85 description (memory-pressure round, with its measurements)
Fixes #83.
Reported on a TCL Flip 2 as "the whole app is a bit slow". Nobody on the dev side has that phone, so everything below is measured on an M5 (2.9 GB, Android 13,
standardDebug, median of 5 cold starts). Debug build, so treat the absolute numbers as directional; the deltas are the point.What was wrong
Two separate problems, and the issue's own hypothesis (the Whisper model) turned out to be half of one of them.
Nothing released under memory pressure. There was no
onTrimMemory,onLowMemoryorComponentCallbacks2anywhere in the tree.MapView.onLowMemory()was never called.WhisperRecognizerhad no release path at all, so even Remove-model left ~267 MB resident for the rest of the process. ATRIM_MEMORY_COMPLETEfreed 0 KB and the system's only remaining option was to kill us.Nothing adapted to the device. No
isLowRamDevicebranch existed; the image cache was a flat 48 MB whatever the phone.Numbers
APK 97,396,968 -> 91,995,103 bytes.
Post-trim and native heap are the numbers to trust: both are PAIRED measurements inside a single
run, so run-to-run variance cancels.
An idle PSS row was published here earlier and has been REMOVED as unsound. It compared a
post-reap reading against a pre-reap one and presented them as one metric, and the headline cell
was n=1. A follow-up on a single consistent clock (median of 3, fixed 35 s and 155 s samples)
gives branch normal-RAM 233,479 KB settled / 192,557 KB post-reap and branch low-RAM 220,236 KB /
187,957 KB - internally consistent, low-RAM below normal as expected. Those are NOT comparable to
the main baseline above, which came from a different harness whose convergence heuristic latches
during the startup ramp. Quoting a delta across the two harnesses would repeat the original
mistake, so no idle delta is claimed. Peak, post-trim, native heap and cold start are all measured
identically on both builds and are unaffected.
The biggest win needed no device gate
The speech model costs ~267 MB while loaded (~101 MB of weights in
scudo:secondaryplus ~146 MB of onnxruntime arena inscudo:primary) and was kept for the whole process on the chance of a mic tap many users never make. It is now dropped after 120 s unused and rebuilt on next use, so the instant first tap asked for on 2026-07-10 is kept while the session-long hold is not.Device-verified:
scudo:secondary111 MB -> 9 MB at the 120 s mark,recognizer releasedlogged from theasr-reaperthread, model rebuilding to 125,836 KB afterwards, same pid throughout (a bad release/rebuild here is a native use-after-free, not an exception).I first made this low-RAM-conditional. That was too cautious and left roomier phones holding 267 MB all session.
Everything else
app/ui/MemoryPressure.ktis the one seam. Registration-based, 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.WebPhotoFetcherhad no reaper at all and pinned a Chromium renderer for the whole session.WhisperRecognizer.release()declines while a listen is in flight.PiperSynthreleases only at CRITICAL, not merely severe: a reload delaying a turn prompt is a missed turn.libmaplibre.soalone carried 23 MB of them.armeabi-v7akept, since 32-bit ARM keypad phones are real.A regression I caught in my own change
The first low-RAM POI subset used 6 terms and silently deleted every park and school pin, reintroducing the exact bug the civic/green terms were added to fix (the ambient layer filter-hides the basemap OSM poi layers at z14+, so those two have no second source). Caught by an A/B screenshot, not by any test. The subset now keeps
schoolandparkdeliberately.Verification, and what was NOT run
Ran:
:core:detekt,:app:detekt(0 smells),:core:test,tests/dead_code/audit_deadcode.sh(PASS),tests/dpad/audit_static.sh(no new violations). Both flavors build;restrictedinstalled, launched, initializedMemoryPressureand rendered correctly. A/B screenshots of the POI change at native geometry, both device classes.One of four geometries ran; three did not.
kyocera-e4810(240x320 @160) passed themapandvoicephases: 2 COVERED, 0 MISSED.sonim-x320,kyocera-duraxe-e4830andsonim-x320-225areUNVERIFIED. The reasoning for not treating that as blocking, as AGENTS.md requires it be stated: The diff contains zero Compose, layout or focus code: no
Modifier, nodpadHighlight, nofocusable, no composable signature change. TheVelaMapViewedit is aDisposableEffectregistration. The matrix exists to catch clipping, focus-ring and D-pad-traversal regressions at small sizes, none of which this diff can reach. The single user-visible change is ambient POI data density, which is geometry-independent and verified by A/B screenshot.If a reviewer disagrees with that blast-radius call, the legs to run are
PHASES="map voice settings"acrosskyocera-e4810,sonim-x320,kyocera-duraxe-e4830,sonim-x320-225.Testing the low-RAM path
Every device we own reports
lowRam=false heapClassMb=256, so those branches would otherwise ship as dead code. Debug builds honour:Two traps worth knowing
am send-trim-memoryrefuses 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. That happened here and produced a whole benchmark of void numbers before it was noticed.AGENTS.md's memory rule said the
OverpassTrafficSignals/OverpassPoisstream-parse follow-up was pending. It has been done for some time, and chasing that stale line wasted a pass. Corrected: the remaining fully-buffered hot reader is the Google ambient path, which cannot simplydecodeFromStreambecause the payload is a positional nameless array.