Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
50e3b7a
Use a lot less memory, and give it back when the system asks
alltechdev Jul 21, 2026
7345474
Hand freed memory back to the OS, not just to the allocator
alltechdev Jul 21, 2026
92beb2a
Stop one search from pinning a Chromium renderer for the whole session
alltechdev Jul 21, 2026
6e49208
Stop the ASR reaper freeing the speech model under a running decode
alltechdev Jul 21, 2026
7dfc2ab
Do not lay out the photo scraper's WebView until it actually scrapes
alltechdev Jul 21, 2026
d4e6dcb
Drop the scraped page when the scrape ends, not two minutes later
alltechdev Jul 21, 2026
e5c8200
Measure the production variant, and correct two claims about the nati…
alltechdev Jul 21, 2026
bd08647
Stop low-RAM phones losing whole POI categories for no memory saving
alltechdev Jul 21, 2026
da37ca7
Stop the low-RAM check missing the phones it was written for
alltechdev Jul 21, 2026
d022056
Verify the low-RAM path on a production build, and stress it for real
alltechdev Jul 21, 2026
8e71081
Build the camera index without allocating 400,000 throwaway objects
alltechdev Jul 21, 2026
f1d21ae
Record what startup profiling actually found, including the biggest u…
alltechdev Jul 21, 2026
bec5279
Record that the obvious MapScreen extraction makes it worse
alltechdev Jul 21, 2026
fc62f01
Close off composable extraction as a way to fix MapScreen
alltechdev Jul 21, 2026
b596ede
The UI is not the bottleneck: 9.0 ms per frame, against a 16.7 ms budget
alltechdev Jul 21, 2026
6ab1c97
Point performance work at content latency, which is 28.8 s, not frame…
alltechdev Jul 21, 2026
443619a
Record why streaming the ambient fan-out is not a drive-by edit
alltechdev Jul 21, 2026
9bce366
Merge main into perf/webview-and-native-purge
alltechdev Jul 23, 2026
452b698
Fix all 12 review findings, fix the armv7 model-load SIGBUS (issue #9…
alltechdev Jul 23, 2026
ffed0ad
Translate the Zipformer engine caption into all 14 locales
alltechdev Jul 23, 2026
c4c06f8
Remove the Zipformer engine; keep the transcript normalization
alltechdev Jul 23, 2026
21b08d7
CI: size the AAR truncation guard for 1.13.4
alltechdev Jul 23, 2026
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
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,27 @@ jobs:
echo "code=${{ github.run_number }}" >> "$GITHUB_OUTPUT"

- name: Fetch TTS runtime AAR
# sherpa-onnx neural-TTS + ASR runtime (Kokoro voice, Whisper voice search). It's a 57 MB
# sherpa-onnx neural-TTS + ASR runtime (Kokoro voice, Whisper voice search). It's a ~47 MB
# prebuilt AAR (no Maven artifact) hosted on the fixed-tag `tts-runtime` release, gitignored
# out of the repo - fetch it into app/libs/ so :app can package the arm64 AND armeabi-v7a
# .so. Verify the size so a truncated download fails loud instead of producing a
# runtime-crashing APK.
# runtime-crashing APK. (The threshold is sized for 1.13.4's 48,847,529 bytes; the 1.13.3
# AAR was 57 MB, and the old >50 MB check failed the UPGRADED artifact - resize this when
# the AAR is bumped, it is a truncation guard, not a version pin.)
run: |
mkdir -p app/libs
# Hosted on this repo's fixed-tag `tts-runtime` release (a one-time manual upload).
curl -fSL -o app/libs/sherpa-onnx-1.13.3.aar \
"https://github.com/${{ github.repository }}/releases/download/tts-runtime/sherpa-onnx-1.13.3.aar"
test "$(stat -c%s app/libs/sherpa-onnx-1.13.3.aar)" -gt 50000000
# 1.13.4 (onnxruntime 1.27.0) is REQUIRED, not preferred: its bundled runtime fixes the
# armv7 unaligned-read SIGBUS that crashed every model load on 32-bit phones (issue #95).
curl -fSL -o app/libs/sherpa-onnx-1.13.4.aar \
"https://github.com/${{ github.repository }}/releases/download/tts-runtime/sherpa-onnx-1.13.4.aar"
test "$(stat -c%s app/libs/sherpa-onnx-1.13.4.aar)" -gt 40000000
# The hosted AAR must actually CARRY 32-bit ARM, not just be big enough. If it is ever
# replaced with an arm64-only build, the v7a strip fix (#81) silently reverts: the APK
# still builds, still installs on a TCL Flip 2, and voice search + Vela voice are dead
# again with an UnsatisfiedLinkError nobody sees until a tester reports it. Fail here
# instead - this is the check whose absence let the original bug ship.
unzip -l app/libs/sherpa-onnx-1.13.3.aar | grep -q "jni/armeabi-v7a/libsherpa-onnx-jni.so" \
unzip -l app/libs/sherpa-onnx-1.13.4.aar | grep -q "jni/armeabi-v7a/libsherpa-onnx-jni.so" \
|| { echo "::error::tts-runtime AAR has no armeabi-v7a sherpa-onnx - 32-bit phones would lose voice search + neural TTS"; exit 1; }

- name: App unit tests
Expand Down
452 changes: 446 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

28 changes: 23 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ android {
// unaffected - it is host-side only.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }

// libvelamem: the mallopt() purge shim (app/src/main/cpp). Built only for the two ABIs the
// ndk filter above ships.
externalNativeBuild {
cmake { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}

// MapTiler key injected from the CI secret (-PmaptilerKey); empty for
// local builds, in which case the app falls back to the keyless
// OpenFreeMap basemap. Never stored in the repo.
Expand Down Expand Up @@ -203,6 +209,16 @@ android {
}
}

// The app's only native code: the mallopt() purge shim. Pinned NDK/CMake versions so a
// developer with a different NDK installed gets the same libvelamem.so as CI.
ndkVersion = "27.0.12077973"
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
Expand Down Expand Up @@ -260,11 +276,13 @@ dependencies {
implementation(project(":core"))
implementation(project(":yapchik")) // vendored softkey engine (LGPL-3.0) - keypad/D-pad softkeys

// sherpa-onnx: in-process neural TTS runtime (runs the downloaded Kokoro model). Vendored AAR
// (no official Maven artifact; the JitPack coordinate doesn't resolve). Lives in :app because a
// library module can't consume a local .aar - KokoroSynth sits in :app and bridges into :core's
// VoiceGuide via an interface. Native .so are arm64-only in the package (see packaging{}).
implementation(files("libs/sherpa-onnx-1.13.3.aar"))
// sherpa-onnx: in-process neural TTS + ASR runtime. Vendored AAR (no official Maven artifact;
// the JitPack coordinate doesn't resolve). Lives in :app because a library module can't consume
// a local .aar. 1.13.4 is a LOAD-BEARING upgrade, not routine: its bundled onnxruntime (1.27.0,
// up from 1.24.3) fixes the armv7 unaligned-read SIGBUS that crashed every model LOAD on 32-bit
// ARM phones (issue #95; device-verified both broken-before and fixed-after on an M5 forced to
// `--abi armeabi-v7a`). Do not downgrade past it while the fork ships v7a.
implementation(files("libs/sherpa-onnx-1.13.4.aar"))
// Extracts the Kokoro model's .tar.bz2 at download time (Android has no built-in bzip2/tar).
implementation("org.apache.commons:commons-compress:1.27.1")

Expand Down
12 changes: 12 additions & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# The app's only native module: a mallopt() purge shim (see velamem.cpp for why).
# Deliberately tiny and STL-free, so it adds a few KB per ABI rather than a runtime.
cmake_minimum_required(VERSION 3.22.1)
project(velamem LANGUAGES CXX)

add_library(velamem SHARED velamem.cpp)

# -Os and no exceptions/RTTI: this is three lines of code calling libc, none of which needs them.
target_compile_options(velamem PRIVATE -Os -fno-exceptions -fno-rtti -fvisibility=hidden)

# Only libc is needed; mallopt lives there. No log lib, the Kotlin side does the logging.
target_link_libraries(velamem)
38 changes: 38 additions & 0 deletions app/src/main/cpp/velamem.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Native-allocator purge. The ONLY reason this module exists.
//
// Issue #83 gave every big holder a release() and fanned OS trims out to them (MemoryPressure), but
// a Kotlin release only hands pages back to the ALLOCATOR, not to the kernel. Scudo keeps them on
// its free lists, so RSS/PSS barely moves and the OOM killer still sees a fat process.
//
// Measured on the M5 (2.9 GB, Android 13, app.vela.debug, PR #85 build, all 8 listeners firing):
// a full TRIM_MEMORY_COMPLETE moved scudo:primary only 56,578 -> 54,978 KB while mallinfo reported
// a 442 MB arena holding just 46 MB live. That gap is what mallopt() reclaims and nothing on the
// Java side can touch.
//
// bionic exposes exactly one lever for it, and only through libc:
// M_PURGE (API 28+) release free memory in the calling thread's arena
// M_PURGE_ALL (API 34+) walk every arena; documented as able to take 2x+ a plain M_PURGE
//
// The values are ABI-stable, so they are spelled out rather than taken from <malloc.h>, which
// keeps the build independent of NDK header vintage. An unsupported option makes mallopt() return
// 0, so calling M_PURGE_ALL on an API 33 device is a harmless no-op that falls through to M_PURGE.

#include <jni.h>
#include <malloc.h>

#ifndef M_PURGE
#define M_PURGE (-101)
#endif
#ifndef M_PURGE_ALL
#define M_PURGE_ALL (-104)
#endif

// Returns which lever actually took, so the Kotlin side can log it and a device that supports
// neither is visible in logcat instead of silently doing nothing:
// 2 = M_PURGE_ALL, 1 = M_PURGE, 0 = neither supported
extern "C" JNIEXPORT jint JNICALL
Java_app_vela_ui_MemoryPressure_nativePurge(JNIEnv*, jobject, jboolean all) {
if (all && mallopt(M_PURGE_ALL, 0) != 0) return 2;
if (mallopt(M_PURGE, 0) != 0) return 1;
return 0;
}
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
Loading
Loading