Skip to content

feat(app): let the learner point the app at a self-hosted server - #15

Merged
IgnacioLD merged 2 commits into
mainfrom
feat/custom-server
Nov 10, 2026
Merged

IgnacioLD merged 2 commits into
mainfrom
feat/custom-server

Conversation

@IgnacioLD

@IgnacioLD IgnacioLD commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Why

F-Droid labels the app TetheredNet because the server is fixed at build time (glossoApiBase). This adds an in-app server setting so anyone can use their own self-hosted Glosso server, and drops TetheredNet from the F-Droid recipe.

What

  • Settings > Advanced > Server (ServerScreen): shows the server in use, takes a custom address, "Check and use", and "Use the default server". The address is normalized (bare host means https://, lowercase, no trailing slash, no query/fragment/user info). https:// is required; debug builds also accept http:// to localhost, 127.x, 10.0.2.2 and private LAN ranges (release Android denies cleartext anyway).
  • Probe before saving (probeServer): /health must answer {"status":"ok", ..., "assets": ...} and /v1/config a JSON object. Clear errors for unreachable vs "answers, but not a Glosso server". The probe client sends no device id.
  • All traffic follows the setting: GlossoApi now takes serverBase: () -> String and reads it per request (ServerPreferences.base, store glosso_server, key base_url). That covers content, cloud scoring, asset/lesson/model downloads and their .sha256, /v1/config, /v1/me, events, crash traces and Google sign-in. Each existing client (every ViewModel keeps its own) notices the switch and forgets its manifest and content version.
  • Switching (ServerSwitch) drops everything from the previous server: active downloads, HTTP cache + content version (ApiCache.forget), cached config and account (ProductConfigRepository.forget, and an in-flight refresh from the old server is discarded), the linked Google account shown on the phone, unsent events and pending crash trace. The scoring model is kept only if its SHA-256 is the pinned release digest or the one the new server publishes (OnDeviceScorer.onServerChanged). Downloaded lesson packs are kept only when the new server's manifest publishes exactly the content fingerprint (assetVersion) they were downloaded for (OfflineStore.keepOnly); a different fingerprint, none, or an unreachable server deletes them. Progress is local and kept. The switch runs NonCancellable. Screens reload through contentReloadSignal (now includes the server).
  • Build-time glossoApiBase (Android) / GLOSSO_API_BASE (iOS) stays the default; iOS uses the same setting through :shared.
  • About shows Server: ... when it is not the default. New server screen name added to the analytics catalogue.
  • Docs: docs/FDROID.md explains why TetheredNet no longer applies; metadata/me.shirobyte42.glosso.yml drops it; README has a "Use your own server" section.
  • Strings in en, es, fr, de.

Verification

  • :engine:jvmTest 463/0 failed, :shared:testDebugUnitTest 146/0, :androidApp:testFdroidDebugUnitTest 296/0, :androidApp:testPlaystoreDebugUnitTest 295/0.
  • Pack retention tests in ServerSwitchTest: same fingerprint keeps the pack; different, missing or unreachable deletes it (the keep test fails against the old always-delete behaviour).
  • New tests: ServerSettingTest (normalization, invalid input, http rules, private ranges, persistence and default fallback) and ServerSwitchTest (probe OK / not Glosso / unreachable on a MockEngine; switch A -> B -> default: downloads cancelled, account forgotten, config and content version from B only, every request after the switch goes to B with no stale ?v=).
  • :androidApp:lintFdroidDebug: 0 errors, 73 warnings, none in changed files (GradleDependency, AGP version, UseKtx, VectorPath, launcher icon, ObsoleteSdkInt).
  • :androidApp:assembleFdroidDebug, :shared:compileKotlinIosSimulatorArm64 pass. gitleaks dir . --redact: only the two known false positives.
  • E2E on emulator (1080x2340, 480 dpi), fdroid debug: http://example.org refused with the https message; http://10.0.2.2:8080/ accepted and normalized; the local server log shows config, manifest, LESSON, STORY, DIALOGUE, EXERCISE and asset requests; the setting survives a reinstall and cold start; the installed model was kept (pinned digest); "Use the default server" switched back with 0 further requests to the local server.

https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng

Settings > Advanced > Server takes a custom base URL, checks it answers like
a Glosso server (/health and /v1/config) and switches every network path to
it: GlossoApi reads the server at request time, so content, cloud scoring,
lesson and model downloads, product config, statistics and sign-in follow.
Switching drops what came from the previous server (queued downloads, HTTP
cache and content version, lesson packs, cached config and account, linked
Google account, unsent events) and keeps the scoring model only when its
digest is pinned or published by the new server. Progress stays.

The build's glossoApiBase / GLOSSO_API_BASE stays the default. About shows
the server when it is not the default. The F-Droid recipe drops TetheredNet.

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
On a server switch, downloaded lesson packs now stay until the new server's
manifest answers. Packs whose content fingerprint (assetVersion) matches it
exactly are kept; any other pack, and every pack when the server publishes no
fingerprint or cannot be reached, is deleted as before. The rest of the switch
(HTTP cache, config, account, events) is unchanged.

Claude-Session: https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng
@IgnacioLD
IgnacioLD merged commit d70a496 into main Nov 10, 2026
4 checks passed
@IgnacioLD
IgnacioLD deleted the feat/custom-server branch November 10, 2026 14:24
IgnacioLD added a commit that referenced this pull request Nov 10, 2026
…ice scoring memory (#16)

Play Console (2.5.4, Android vitals) warns that the R8 setup may use
more memory because optimized resource shrinking is off, and says to
move to AGP 9. Google starts enforcing these memory and code
optimization thresholds in February 2027. This PR upgrades the build,
removes keep rules that are no longer needed, and lowers the memory that
on-device scoring holds. There are three commits, one per concern.

## 1. AGP 8.9.1 -> 9.3.3, Gradle 8.11.1 -> 9.7.1
- **Why 9.3.3 and not 9.5.1:** 9.3.3 is the newest AGP inside Kotlin
2.4.20's tested range (AGP 8.5.2-9.3.1, Gradle 7.6.3-9.7.0). 9.4.x and
9.5.x are outside that range, and 9.5.x was released less than two weeks
ago. AGP 9.3 needs at least Gradle 9.5. I picked Gradle 9.7.1, the
version `deploy/Dockerfile` already builds the server with. Both picks
are patch releases of tested versions. The wrapper jar matches the
official sha256.
- **KMP modules:** `:engine` and `:shared` now use
`com.android.kotlin.multiplatform.library`, because AGP 9 rejects
`com.android.library` next to the KMP plugin. These stay as they were:
the opt-in Android target (`-Pglosso.android`), `onnxMain`,
`kotlin.mpp.applyDefaultHierarchyTemplate=false` and JVM target 17.
- **`:androidApp`:** uses AGP's built-in Kotlin, so the `kotlin-android`
plugin is gone and the JVM target follows `compileOptions`.
`resourceConfigurations` is replaced by
`androidResources.localeFilters`. Both flavors, the licensing gate per
flavor and the release `-P` inputs are unchanged.
- **Test moves:**
- `shared/src/androidUnitTest` is now `androidHostTest`, so the task is
`:shared:testAndroidHostTest`.
- `BottomActionBarTest`, `GlossoArtworkTest` and `PlayerPointerTest`
stopped compiling in `:androidApp` once the UI moved to `:shared`: they
use `internal` API, and one still imported Coil 2. That broke
`connectedAndroidTest` and `tools/score-parity/run-parity-test.sh` on
main. They now live in `shared/src/androidDeviceTest`
(`:shared:connectedAndroidDeviceTest`).
- **Gradle 9.7 deprecations fixed:** delegated `by
getting/creating/registering` in `engine`, `shared` and `androidApp`,
and `Task.project` read at execution time by the licensing tasks.
- **Licensing:** the locks and `licenses.json` are regenerated with
`tools/licensing/collect.py`. `kotlin-stdlib-common:2.4.20` now
resolves.
- **Docs:** README and CONTRIBUTING list `:shared:testAndroidHostTest`.

## 2. R8
- Optimized resource shrinking is AGP 9's default. `isMinifyEnabled` and
`isShrinkResources` were already on for release.
- **Rules removed from `proguard-rules.pro`:**
  - the kotlinx.serialization block, which the library already ships;
- `-keep class androidx.credentials.playservices.** { *; }`, because the
AAR ships a narrower rule;
  - the Coil 2 rules, which named classes that no longer exist;
- every blanket `-dontwarn`, because R8 reports no missing class without
them;
  - the duplicate native-method rule;
- `-renamesourcefileattribute`, so R8 9 writes its mapping id as the
source file.
- **Rule kept:** `ai.onnxruntime.**`. The JNI layer looks up classes,
fields and constructors by name, and the AAR ships rules only for its
telemetry classes.

| Release, unsigned | before (main d70a496) | after |
|---|---|---|
| playstore APK size | 138,753,248 B | 138,421,983 B |
| playstore referenced methods | 30,511 | 26,178 (-14%) |
| playstore dex | 5,119,168 B | 4,621,976 B |
| playstore `res/` files / resources.arsc | 277 / 113,228 B | 13 /
44,704 B |
| fdroid APK size | 138,232,808 B | 138,227,046 B |
| fdroid referenced methods | 25,514 | 23,963 |
| fdroid dex | 4,463,520 B | 4,291,760 B |
| playstore AAB | 60,310,866 B | 59,993,510 B |

Methods were counted with `apkanalyzer dex references`. Most of the APK
is the ONNX Runtime `.so` for 4 ABIs (about 135 MB), so the total size
barely moves.

## 3. On-device scoring memory
- **Model load:** the model was already loaded from the file path, not
through a ByteArray.
- **Session options:** the session is still created once and kept. It
now runs with `setCPUArenaAllocator(false)` and
`session.use_device_allocator_for_initializers=1`. These change only
where tensors are allocated, not the math.
- **Background release:** `OnDeviceScorer` releases the model on
`TRIM_MEMORY_BACKGROUND` and reloads it on the next take. A take holds
the load lock while it runs, so the session cannot be closed under it.
- **Not changed:** the model stays loaded when the learner leaves
practice but stays in the app, to avoid reloading it on every screen.
- **Bit-exact check:** a SHA-256 over the recognition (IPA, per-token
confidences, timings) of `score-parity/fixtures/good.wav` was
`1f3c5e77...` before and after. `tools/engine-bench/run.sh` output is
identical line for line between main and this branch.

**Memory on emulator-5558** (arm64, 1080x2340 at 480 dpi), debug build.
A scratch instrumented probe (not committed) walks through the phases
while `adb shell dumpsys meminfo me.shirobyte42.glosso` is sampled every
0.5 s. Values are MB at the end of each phase:

| Phase | PSS before | PSS after | Native heap before | Native heap
after | Java heap before -> after |
|---|---|---|---|---|---|
| idle | 183 | 187 | 4 | 6 | 9 -> 10 |
| model loaded | 589 | 592 | 400 | 400 | 9 -> 10 |
| after 3 scored takes | 610 | 574 | 416 | 378 | 13 -> 13 |
| second recognizer loaded (peak) | 1018 | 918 | 826 | 724 | 11 -> 10 |
| second recognizer closed | 639 | 588 | 447 | 394 | 10 -> 10 |
| `onTrimMemory(TRIM_MEMORY_BACKGROUND)` | 638 | 230 | 447 | 36 | 10 ->
10 |

Scoring latency for the fixture was unchanged within noise (321/291/268
ms before, 337/310/265 ms after).

## Verification (rebased on main d70a496)
- **Unit tests:**
  - `./gradlew :engine:jvmTest`: 463 passed
  - `LC_ALL=en_US.UTF-8 ./gradlew :server:test`: 347 passed
- `:androidApp:testFdroidDebugUnitTest` / `testPlaystoreDebugUnitTest`:
296 / 295 passed
- `:shared:testAndroidHostTest`: 146 passed. The same test files ran as
`testDebugUnitTest` before the move: 142 on the old base, plus the tests
that came with #15.
- **Lint:** `:androidApp:lintFdroidDebug` and `lintPlaystoreDebug`
report 0 errors. Warnings went from 73 to 11 per flavor. The one new
warning is `OldTargetApi`: the newer lint knows API 37, and raising
`targetSdk` is a separate product change.
- **iOS:** `:engine:compileKotlinIosSimulatorArm64` and
`:shared:compileKotlinIosSimulatorArm64` pass.
- **Server image path:** `./gradlew :server:installDist
-Pglosso.android=false -Pglosso.ios=false` passes without the Android
SDK in the environment. Docker is not available on this machine, so
`docker build` was not run. The Dockerfile already uses Gradle 9.7.1.
- **Release builds:** `assemblePlaystoreRelease`,
`assembleFdroidRelease` and `bundlePlaystoreRelease` pass.
- **Release on device:** I used a temporary local setup, not committed:
`testBuildType = "release"`, debug signing, and keep rules for the test
harness only. `ScoreParityTest` passed on the R8-minified playstore
release on emulator-5558: good.wav scored 100 / PERFECT with delta 0,
and silence, noise and low were not evaluable. ONNX Runtime loads and
scores under the new rules. `OfflineReferenceParityTest` skipped because
`thirty.wav` is not staged anywhere.
- **Debug on device:** `tools/score-parity/run-parity-test.sh --serial
emulator-5558` passed. `:shared:connectedAndroidDeviceTest` ran 6 tests
and all passed.
- **Engine bench:**
`GLOSSO_MODEL=<abs>/wav2vec2_phoneme_int8_dyn_pertensor.onnx
tools/engine-bench/run.sh` gives native >= 98 for 30/45 and REFSCORE
22/28 (>= 98), identical on main and this branch. The bench's own 0.9
rate gate already fails on main; this PR does not change that.
- **gitleaks:** `gitleaks dir . --redact` reports only the 2 known false
positives.

## Notes
- AGP prints an informational warning that `:engine`'s `commonTest` has
no Android host test. Turning one on fails to compile, because
`ParityPlatform` has no Android `actual`. The old `androidUnitTest`
variant never compiled either.
- No NDK r28 is installed locally, so "Unable to strip
libonnxruntime.so" is printed, as it was before. The libraries are
already stripped.

https://claude.ai/code/session_01FcVpZoyr8VdRLp1QecX1Ng

---------

Co-authored-by: IgnacioLD <178957758+IgnacioLD@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant