diff --git a/CLAUDE.md b/CLAUDE.md index 26e0f7f3..330bec4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/ChatViewModel.kt b/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/ChatViewModel.kt index 9d3b09c7..d79a9afd 100644 --- a/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/ChatViewModel.kt +++ b/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/ChatViewModel.kt @@ -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().filesDir, MODEL_COPY_NAME).delete() + } + } + /** Saves the current conversation (and its model path) to private local storage. */ fun saveSession() { val snapshot = _uiState.value diff --git a/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/MainActivity.kt b/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/MainActivity.kt index fdb66621..f990abcb 100644 --- a/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/MainActivity.kt +++ b/android-llmservice/app/src/main/kotlin/net/ladenthin/android/llmservice/MainActivity.kt @@ -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), diff --git a/android-llmservice/requirements.md b/android-llmservice/requirements.md index da3fe808..c3e4a1ec 100644 --- a/android-llmservice/requirements.md +++ b/android-llmservice/requirements.md @@ -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 @@ -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 |