Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1670,8 +1670,8 @@ Structure (mirrors the consumer-test's plumbing):
working data** (the copied model `current-model.gguf` + cache) on every **cold start**
(`onCreate`) — a privacy guarantee independent of the OS calling `onDestroy`; `MainActivity` also
wipes best-effort on finish. Only the **opt-in saved session** persists. **`MainActivity.kt`** —
Compose UI with a **two-row app bar** (row 1 = horizontally-scrollable model name on its own line,
row 2 = all action icons) + SAF picker + flag menu + Save/Load + the ⚙️ Settings sheet
Compose UI with a **two-row app bar** (row 1 = horizontally-scrollable model name + a ❌ unload
button when a model is loaded, row 2 = all action icons) + SAF picker + flag menu + Save/Load + the ⚙️ Settings sheet
(sampling knobs + a **Model** section: CPU threads / context length applied via **Reload model**) +
the 🧾 log strip/viewer + Stop/Copy/Regenerate/Clear chat actions (+ **long-press any bubble to
copy**) + **prompt shortcut chips** + a **device-readiness card** (free RAM / storage / battery via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,34 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
log("Chat cleared")
}

/**
* Unloads the current model: cancels any generation, frees the native memory, and deletes the
* copied working model file (privacy + storage). The chat history is kept (still saveable /
* copyable), and the UI returns to the "no model loaded" state so a new model can be picked.
* No-op if no model is loaded.
*/
fun unloadModel() {
if (model == null && _uiState.value.modelState == ModelState.NONE) {
return
}
generation?.cancel()
generation = null
val toClose = model
model = null
modelPath = null
chatTemplate = null
_uiState.update {
it.copy(modelState = ModelState.NONE, modelName = null, generating = false, error = null)
}
log("Model unloaded")
viewModelScope.launch(Dispatchers.IO) {
toClose?.close()
// Drop the copied working model (frees storage; nothing lingers). A model loaded by an
// absolute path (test / session restore) has no copy here, so this is then a no-op.
File(getApplication<Application>().filesDir, MODEL_COPY_NAME).delete()
}
}

/** Saves the current conversation (and its model path) to private local storage. */
fun saveSession() {
val snapshot = _uiState.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,24 @@ private fun ChatScreen(viewModel: ChatViewModel) {
// readable / draggable), and all the action icons sit on a second line below it.
Surface(shadowElevation = 3.dp) {
Column(modifier = Modifier.fillMaxWidth().statusBarsPadding()) {
// Row 1: model name, horizontally scrollable (drag a long name into view; no
// auto-marquee wobble).
Text(
text = state.modelName ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
softWrap = false,
modifier = Modifier.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 10.dp),
)
// Row 1: model name (horizontally scrollable — drag a long name into view; no
// auto-marquee wobble) + a ❌ button to unload the model when one is loaded.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = state.modelName ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
softWrap = false,
modifier = Modifier.weight(1f)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 10.dp),
)
if (state.modelState == ChatViewModel.ModelState.READY) {
IconButton(onClick = { viewModel.unloadModel() }) {
Text("❌", modifier = Modifier.testTag("unloadButton"))
}
}
}
// Row 2: all action icons on their own line.
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
Expand Down
3 changes: 2 additions & 1 deletion android-llmservice/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ by hand only (no automated test); `build` = enforced at build/resource-compile t
| R3.5 | Loading a new model **closes** the previously loaded one (native memory is not GC-managed). | `ChatViewModel.openModel` | manual |
| R3.6 | Recommended real model for coherent replies: an instruct GGUF (default **Gemma 3 4B Instruct**); base/completion models still run. | `README.md` | manual |
| R3.7 | The model-picker screen shows a **device-readiness card**: free/total **RAM** (`ActivityManager.MemoryInfo`), free **storage** (`StatFs`), and **battery** level + charging (`BatteryManager`) — so the user can gauge whether a model fits before loading. Read once at composition. | `DeviceInfo`; `MainActivity.DeviceCard` | manual |
| R3.8 | **Unload model:** a ❌ button next to the model name (shown only when a model is loaded) frees the native model from memory, deletes the copied working model file, and returns to the "no model loaded" state. The **chat history is kept** (still saveable/copyable); a generation in flight is cancelled first. | `MainActivity` (`unloadButton`); `ChatViewModel.unloadModel` | manual |

## R4 — Chat & streaming

Expand Down Expand Up @@ -127,7 +128,7 @@ by hand only (no automated test); `build` = enforced at build/resource-compile t

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R10.1 | The app bar is **two rows**: row 1 is the **model-name title on its own full-width line**, **horizontally scrollable** (draggable) so a long name can be read in full (single line, no auto-marquee); row 2 holds **all action icons** (save / load / clear / settings / language) on their own line below it. | `MainActivity` top bar | manual |
| R10.1 | The app bar is **two rows**: row 1 is the **model-name title** (own full-width line, **horizontally scrollable** / draggable, single line, no auto-marquee) plus a **❌ unload button** at its right when a model is loaded (R3.8); row 2 holds **all action icons** (save / load / clear / settings / language) on their own line below it. | `MainActivity` top bar | manual |
| R10.2 | The release `signingConfig` reads an **upload keystore** from env vars / `-P` props, and **falls back to debug signing** when none is set (so forks/PRs/local builds stay green). **Upgrade caveat:** the debug fallback uses an ephemeral per-runner key, so debug-signed CI APKs are **not** mutually upgradeable (a signer change is rejected → uninstall required); stable in-place upgrades need the upload-key secrets. | `app/build.gradle.kts` | build |
| R10.3 | The build produces a release **`.aab`** (for Play) and an installable release **`.apk`** (sideload); the Maven Central GPG key cannot sign these — Android needs a Java keystore upload key. | `app/build.gradle.kts`; `README.md` | build |
| R10.4 | `versionCode` is **monotonic** (derived from `GITHUB_RUN_NUMBER` in CI, strictly increasing per run; `-PappVersionCode` overrides; local hand builds use `1`), so successive release APKs advertise a higher version and Android accepts the in-place upgrade (given a stable signer, R10.2). | `app/build.gradle.kts` | build |
Expand Down
Loading