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
31 changes: 23 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1629,9 +1629,12 @@ releases as a signed Central Portal bundle upload (staging repo → zip → Publ
A shippable, **KISS fully-offline on-device chat app** consuming the `llama-android` AAR +
`llama-kotlin` façade. App label **"LLM Service"**, applicationId/namespace/package
**`net.ladenthin.android.llmservice`**. One Compose screen: pick a GGUF from the file system
(Storage Access Framework), stream a chat reply fully on-device, switch UI language via a
**flag picker (13 languages)**, and **Save/Load** the conversation to **private local storage**.
No `INTERNET`/storage permission — nothing leaves the device. Like `llama-android/` and
(Storage Access Framework), stream a chat reply fully on-device, **tune sampling** via a Settings
sheet (⚙️ — temperature/Top-K/Top-P/Min-P/**repeat penalty**/repeat range/max tokens; the
repeat-penalty default is what stops the small-model repetition loop), **Stop/Copy/Regenerate/Clear**
the chat, watch an **in-app log** (bottom strip + full viewer with copy-all / save-as-`.txt` / clear),
switch UI language via a **flag picker (13 languages)**, and **Save/Load** the conversation to
**private local storage**. No `INTERNET`/storage permission — nothing leaves the device. Like `llama-android/` and
`.github/android-consumer-test/`, it is a **standalone plain-Gradle/AGP build, NOT a Maven
reactor module** (and NOT published to Maven Central — it is an app, not a library). It is the
"real app" counterpart to the headless `.github/android-consumer-test/` fixture (which only
Expand All @@ -1658,16 +1661,28 @@ Structure (mirrors the consumer-test's plumbing):
ViewModel survives the locale-change recreation, so the chat isn't lost. `app_name` ("LLM Service")
is the one string deliberately NOT translated.
- **`ChatViewModel.kt`** — the logic: loads a `LlamaModel` (SAF `content://` copied into `filesDir`
because llama.cpp mmaps a real path), streams via `generateChatFlow`; the localized system prompt is
passed in from the UI. **`SessionStore.kt`** — private local save/load: the conversation + model path
as JSON in `filesDir/session.json` (`org.json`, no dep added), readable only by the app.
**`MainActivity.kt`** — Compose UI + SAF picker + flag menu + Save/Load buttons; reads optional
`MODEL_PATH` / `CHAT_TEMPLATE` intent extras as a **test hook** (the shipping UI never sets them).
because llama.cpp mmaps a real path), streams via `generateChatFlow` and forwards the `GenerationSettings`
sampling knobs to `InferenceParameters`; supports **stop** (`generation?.cancel()` — cancellation keeps
the partial reply, not an error) and **regenerate** (drop the trailing reply, re-run the last user turn,
shared `startGeneration` path); keeps a capped rolling in-app **log**. **`SessionStore.kt`** — private
local save/load: the conversation + model path as JSON in `filesDir/session.json` (`org.json`, no dep
added), readable only by the app. **`MainActivity.kt`** — Compose UI + SAF picker + flag menu +
Save/Load + the ⚙️ Settings sheet + the 🧾 log strip/viewer + Stop/Copy/Regenerate/Clear chat actions +
the offline badge + the horizontally-scrollable model-name title; reads optional `MODEL_PATH` /
`CHAT_TEMPLATE` intent extras as a **test hook** (the shipping UI never sets them).
- **`app/src/androidTest/kotlin/.../ChatFlowInstrumentedTest.kt`** — the real end-to-end test:
launches the activity with a preloaded model (bypassing the system SAF dialog, which only
UiAutomator could drive), types a prompt, taps Send, asserts a non-empty streamed reply.
Self-skips (JUnit `Assume`) when the adb-pushed model is absent.

**Requirements / roadmap docs (keep in sync with the code).** The app has **no unit tests** for its
logic (only the one on-device `ChatFlowInstrumentedTest` above), so
[`android-llmservice/requirements.md`](android-llmservice/requirements.md) is the **spec of record** —
it enumerates, with stable `Rn.m` IDs, every feature the app implements today. Any change to app
behavior must update `requirements.md` in the same commit (add/amend/retire a row); the roadmap of
not-yet-built features lives in [`android-llmservice/TODO.md`](android-llmservice/TODO.md), and
`README.md`'s feature claims must match `requirements.md`.

**Recommended real model: Gemma 3 4B Instruct** (a `*-it` GGUF; carries its own chat template, so
no override needed). Any instruct GGUF works. The CI on-device test uses the tiny cached draft model
with a forced `chatml` template just to prove tokens generate.
Expand Down
2 changes: 2 additions & 0 deletions android-llmservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ gradle -p android-llmservice connectedDebugAndroidTest -PjllamaVersion="$VERSION
android-llmservice/
├── settings.gradle.kts # AGP 8.7.3 + Kotlin 2.4.0 + Compose plugin, mavenLocal first
├── gradle.properties
├── requirements.md # spec of record: every feature the app implements today (no unit tests)
├── TODO.md # roadmap: not-yet-built features
└── app/
├── build.gradle.kts # minSdk 28, Compose, release signingConfig (upload key/debug fallback)
├── proguard-rules.pro # stub — the AAR ships the JNI keep rules
Expand Down
4 changes: 3 additions & 1 deletion android-llmservice/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,6 @@ See decision #1. All rows below assume a new in-app Ktor/NanoHTTPD HTTP layer wr
JNI core, OpenAI/Ollama routes, API key + LAN toggle + warnings, WebUI, foreground service. This is
the largest single effort and the brief's flagship differentiator.

> Repo-wide TODOs live in the root [`TODO.md`](../TODO.md); this file scopes only the `android-llmservice` app.
> This file is the **roadmap** (not-yet-built features). What the app **currently implements** is
> specified in [`requirements.md`](requirements.md) — when a TODO item ships, flip it to ✅ here **and**
> add/expand its requirement there. Repo-wide TODOs live in the root [`TODO.md`](../TODO.md).
146 changes: 146 additions & 0 deletions android-llmservice/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<!--
SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>

SPDX-License-Identifier: MIT
-->
# LLM Service — requirements (current behavior)

The **specification of record** for what the **LLM Service** Android app (`android-llmservice/`)
does **today**. Because the app has **no unit tests** for its logic — the only automated check is
the single on-device `ChatFlowInstrumentedTest`, which exercises just the core load→type→send→stream
path — this file is the authoritative description of the app's behavior. Each requirement is written
to be individually testable, so tests can later be written against these IDs.

**Relationship to the other docs (keep all three in sync):**

- **`requirements.md`** (this file) — what the app **currently implements** (shipped behavior).
- **`TODO.md`** — the **roadmap** (not-yet-built features, t-shirt-sized). When a TODO item ships,
flip it to ✅ in `TODO.md` **and** add/expand the matching requirement here.
- **`README.md`** — the human-facing overview. Its feature claims must match this file.

**Sync rule:** any change to app behavior (a new feature, a changed default, a removed control)
**must** update this file in the same commit — add a new `Rn.m` row, amend the affected row, or mark
it removed. Do not let a shipped behavior exist without a requirement here. IDs are **stable**: never
renumber; retire a requirement by marking it ~~struck through~~ with a note rather than reusing its ID.

**"Verified by" legend:** `instrumented` = covered by `ChatFlowInstrumentedTest`; `manual` = verified
by hand only (no automated test); `build` = enforced at build/resource-compile time.

---

## R1 — App identity & packaging

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R1.1 | The app label is **"LLM Service"** (not translated — same in every locale). | `values/strings.xml` `app_name`; `AndroidManifest.xml` | manual |
| R1.2 | applicationId / namespace / Kotlin package is **`net.ladenthin.android.llmservice`**. | `app/build.gradle.kts` | build |
| R1.3 | Minimum SDK is **28** (Android 9 Pie — the AAR floor); target/compile SDK **35**. | `app/build.gradle.kts` | build |
| R1.4 | Native ABIs shipped: **`arm64-v8a`** (phones) and **`x86_64`** (emulators / x86 Android). | `app/build.gradle.kts` | build |
| R1.5 | Depends on `net.ladenthin:llama-android` (AAR) + `net.ladenthin:llama-kotlin` (façade), version selectable via `-PjllamaVersion`. | `app/build.gradle.kts` | build |
| R1.6 | It is a standalone plain-Gradle/AGP build (NOT a Maven reactor module) and is **not** published to Maven Central. | repo layout | manual |

## R2 — Privacy & network posture

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R2.1 | The app declares **no `INTERNET` permission** and **no storage permission** — nothing leaves the device. | `AndroidManifest.xml` | build |
| R2.2 | `allowBackup=false` — conversation data is not included in system backups. | `AndroidManifest.xml` | build |
| R2.3 | All inference runs **on-device** (CPU); no request is ever made to a remote server by the app. | `ChatViewModel` (no network calls) | manual |
| R2.4 | A **"🔒 Offline · fully on-device"** badge is shown on the model-picker screen. | `MainActivity.OfflineBadge`; `badge_offline` | manual |

## R3 — Model selection & loading

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R3.1 | The user picks a GGUF via the **Storage Access Framework** (`ACTION_OPEN_DOCUMENT`, `*/*`) — no storage permission. | `MainActivity` picker | manual |
| R3.2 | A picked `content://` model is **copied into app-private `filesDir`** (`current-model.gguf`) and loaded **by real path** (llama.cpp mmaps a real path, not a `content://` URI). | `ChatViewModel.copyToInternal` | manual |
| R3.3 | The model loads **CPU-only** (`setGpuLayers(0)`) with context size **2048**, portable across every device. | `ChatViewModel.openModel` (`CONTEXT_SIZE`) | manual |
| R3.4 | While loading, a **LOADING** state is shown (spinner + "Loading model…"); on failure a localized load error is shown and state returns to NONE. | `ChatViewModel.ModelState`; `error_load_model` | manual |
| 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 |

## R4 — Chat & streaming

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R4.1 | The assistant reply **streams token-by-token** into the UI over the `llama-kotlin` `generateChatFlow` façade; native work runs on `Dispatchers.IO`. | `ChatViewModel.startGeneration` | instrumented |
| R4.2 | Messages render as **bubbles** (user vs assistant styling); the list **auto-scrolls** to the newest message. | `MainActivity.MessageBubble` / `Conversation` | manual |
| R4.3 | A **localized system prompt** is passed on every turn, nudging the model to answer in the user's language. | `system_prompt`; `ChatViewModel.send` | manual |
| R4.4 | `send` is a **no-op** when the input is blank, no model is loaded, or a generation is already in flight. | `ChatViewModel.send` | manual |
| R4.5 | An optional **chat-template override** (e.g. `chatml`) is supported for template-less GGUFs (used by the test hook; real instruct models carry their own template). | `ChatViewModel.chatTemplate` | instrumented |
| R4.6 | A generation error keeps the partial reply and surfaces a localized generation error. | `ChatViewModel.startGeneration`; `error_generation` | manual |

## R5 — Generation settings (sampling)

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R5.1 | A **Settings sheet** (⚙️ in the top bar) exposes live sliders for **temperature, Top-K, Top-P, Min-P, repeat penalty, repeat range, max tokens**, plus **Reset defaults**. | `MainActivity.SettingsDialog`; `ChatViewModel.GenerationSettings` | manual |
| R5.2 | Defaults are **temperature 0.7, Top-K 40, Top-P 0.95, Min-P 0.05, repeat penalty 1.1, repeat range 64, max tokens 256**. | `ChatViewModel.GenerationSettings.DEFAULT` | manual |
| R5.3 | All seven knobs are forwarded verbatim to `InferenceParameters` on every generation. | `ChatViewModel.startGeneration` | manual |
| R5.4 | The **repeat penalty > 1 with a non-zero repeat range is required** — it is what prevents the degenerate repetition loop small on-device models fall into with a bare decode. | `ChatViewModel.startGeneration` (comment) | manual |
| R5.5 | Slider ranges: temperature `0–2`, Top-K `0–100`, Top-P/Min-P `0–1`, repeat penalty `1–2`, repeat range `0–256`, max tokens `16–1024`. | `MainActivity.SettingsDialog` | manual |

## R6 — Chat actions

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R6.1 | **Stop:** while a reply streams, the Send button is replaced by a **Stop** button that cancels the generation, **keeps the partial reply**, and shows **no** error (cancellation is not treated as a failure). | `MainActivity.Conversation`; `ChatViewModel.stopGeneration` | manual |
| R6.2 | **Copy:** the last assistant reply can be copied to the clipboard (with a "Copied" toast). | `MainActivity.Conversation` (`copyButton`) | manual |
| R6.3 | **Regenerate:** re-runs the last user turn — drops the trailing assistant reply and regenerates from the conversation up to that user turn (using the current sampling settings). | `MainActivity` (`regenerateButton`); `ChatViewModel.regenerate` | manual |
| R6.4 | Copy/Regenerate appear only when idle and the last message is a **non-empty assistant reply**. | `MainActivity.Conversation` | manual |
| R6.5 | **Clear chat:** 🗑 in the top bar (enabled only with messages present and not generating) clears the conversation after a **confirm dialog**. | `MainActivity` (`clearButton`); `ChatViewModel.clearChat` | manual |

## R7 — In-app log

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R7.1 | An **always-visible one-line log strip** sits at the very bottom (🧾, small font, may wrap to 2 display lines) showing the newest log line, or an empty-state string. | `MainActivity.LogStrip` | manual |
| R7.2 | Tapping the strip opens a **full-screen log viewer** with **Copy all**, **Save as…** (writes a `.txt` via SAF `CreateDocument`), and **Clear**; ✕ (top-right) closes it. | `MainActivity.LogDialog` | manual |
| R7.3 | The log is a **rolling buffer capped at 500 lines**, each entry prefixed with a local wall-clock time. | `ChatViewModel.log` (`MAX_LOG_LINES`) | manual |
| R7.4 | Logged events include: model loading/ready/load-failure, generation start (with the effective sampling knobs), reply-complete (char count), generation-stopped, generation-failure, regenerate, chat-cleared, settings-reset, and session save/load. | `ChatViewModel` (`log(...)` call sites) | manual |

## R8 — Session persistence (private, local)

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R8.1 | **Save** (💾) writes the conversation **and the model's on-device path** to a single JSON file in app-private storage (`filesDir/session.json`), readable only by this app. | `ChatViewModel.saveSession`; `SessionStore` | manual |
| R8.2 | **Load** (📂) restores the saved messages and, if the model file is still present and different from the current one, **re-opens the model**. | `ChatViewModel.loadSession` | manual |
| R8.3 | Save/Load are **opt-in** (nothing autosaves) and fully local (never uploaded). | `ChatViewModel` | manual |
| R8.4 | Save/Load/none outcomes are surfaced as localized one-shot **snackbar notices**. | `MainActivity` `noticeRes`; `toast_session_*` | manual |

## R9 — Internationalization

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R9.1 | Every user-facing string is a resource; translations ship for **13 languages** (en, de, es, fr, it, pt, ru, tr, ar, hi, zh-CN, ja, ko) plus a **System** (device-language) option. | `values/` + `values-*/strings.xml`; `Languages.kt` | build |
| R9.2 | A **flag-emoji dropdown** in the top bar switches language **in-app** via AndroidX per-app locales (`AppCompatDelegate.setApplicationLocales`), persisted across restarts and integrated with the Android 13+ per-app language setting. | `MainActivity.LanguageMenu`; `res/xml/locales_config.xml` | manual |
| R9.3 | The ViewModel **survives** the locale-change activity recreation, so the chat is not lost when switching language. | `MainActivity` (`by viewModels()`) | manual |
| R9.4 | `app_name` ("LLM Service") is the one string deliberately **not** translated. | `values*/strings.xml` | manual |
| R9.5 | The set of translated strings is **consistent across all locales** — every key in the default resources has a translation in each `values-*` file. | `strings.xml` files | build |

## R10 — UI shell & signing

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R10.1 | The top-bar **model-name title is horizontally scrollable** (draggable), so a long name can be read in full — single line, no auto-marquee. | `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). | `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 |

## R11 — Testing hooks & automated coverage

| ID | Requirement | Source | Verified by |
|---|---|---|---|
| R11.1 | `MainActivity` reads optional `EXTRA_MODEL_PATH` / `EXTRA_CHAT_TEMPLATE` intent extras to preload a model by absolute path, bypassing the SAF picker — a **test hook** the shipping UI never sets. | `MainActivity` companion | instrumented |
| R11.2 | `ChatFlowInstrumentedTest` launches the app with a preloaded model, types a prompt, taps Send, and asserts a **non-empty streamed assistant reply**; it self-skips when the adb-pushed model is absent. | `androidTest/.../ChatFlowInstrumentedTest.kt` | instrumented |
| R11.3 | **Coverage gap:** there are currently **no unit tests** for `ChatViewModel`, `SessionStore`, `Languages`, or the settings/log/chat-action logic. This file is the spec of record until such tests exist. | — | — |

---

## Known coverage gaps / follow-ups

- No unit tests for the ViewModel/session/settings/log logic (R11.3). Candidates: `SessionStore` round-trip
(JSON save→load), `flagForActiveTags` mapping, `GenerationSettings` defaults/reset, `regenerate`
history-trimming, log capping at `MAX_LOG_LINES`.
- Per-message copy (any bubble, not just the last reply) is a possible follow-up (see `TODO.md` §3).
- Roadmap items (model manager, server screen, download manager, multimodal, theming, onboarding)
live in [`TODO.md`](TODO.md) — not part of the current requirements above.
Loading