diff --git a/CHANGELOG.md b/CHANGELOG.md index 9589192..d3be4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,30 @@ and the project loosely follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- **No-account first run** — the model catalog now leads with a **Recommended** + section of ungated (Apache-2.0 / MIT) models that download with no Hugging Face + token; the app picks the best one that fits the device's RAM + (`recommendedModelId`). The license-gated **Gemma** tier moves behind a + collapsible **Advanced — Hugging Face account** section together with the token + field. This makes a fresh install (including a Play Store user with no HF + account) reach a working model in one tap. +- **First-run terms/source gate** — the final onboarding slide links the AGPL + source and Google's Gemma Terms, and clarifies that downloaded models carry + their own licenses. +- **Play Store readiness** — added [`PRIVACY.md`](PRIVACY.md) (hosted-ready, + zero-telemetry privacy policy) and [`PLAY_STORE.md`](PLAY_STORE.md) (a detailed + submission checklist: AAB, signing, Data Safety answers, permissions, content + rating, listing assets, compliance, and follow-ups). The policy is also served + as a static GitHub Pages site under [`docs/`](docs/), and **Settings → About → + Privacy policy** opens the hosted URL in-app. + +### Changed +- **Cleartext traffic disabled** — `usesCleartextTraffic="false"` plus a + `network_security_config.xml` (`cleartextTrafficPermitted=false`). Model + downloads are HTTPS; local peer-to-peer sync uses raw sockets carrying + AES-GCM ciphertext and is unaffected. + ## [0.8.0] — 2026-06-03 ### Added diff --git a/PLAY_STORE.md b/PLAY_STORE.md new file mode 100644 index 0000000..00a2815 --- /dev/null +++ b/PLAY_STORE.md @@ -0,0 +1,150 @@ +# Publishing NativeLM to Google Play — readiness checklist + +This is the end-to-end checklist for shipping the **NativeLM** sample app +(`com.nativelm.app`) to the Google Play Store. Items are grouped by whether they +are **done in the repo**, **done at build time**, or **done in the Play +Console**. Check each before your first production submission. + +Legend: ✅ done in this repo · 🛠️ you do at build/release time · 🌐 you do in +the Play Console · ⏭️ recommended follow-up (not blocking). + +--- + +## 1. Legal & compliance + +- ✅ **Privacy policy authored & hosting prepared** — source in + [`PRIVACY.md`](PRIVACY.md); a static GitHub Pages site is committed under + [`docs/`](docs/) (`docs/index.html`, `docs/privacy/index.html`, `docs/.nojekyll`). + - 🌐 **Enable GitHub Pages (one-time, free for this public repo):** after this + merges to `main`, go to **Settings → Pages → Build and deployment**, set + **Source = "Deploy from a branch"**, **Branch = `main`**, **Folder = `/docs`**, + and Save. Pages is **free** for public repositories. + - 🌐 The privacy URL to paste into **Play Console → Policy → App content → + Privacy policy** will then be: + **`https://sagar-develop.github.io/litertlm-kmp/privacy/`** + - The site root (`https://sagar-develop.github.io/litertlm-kmp/`) is a small + landing page linking the policy and the source. +- ✅ **In-app terms/source disclosure** — the final onboarding slide links the + AGPL source and Google's Gemma Terms, and the gated-model download flow + reminds users to accept the model license on Hugging Face. +- 🌐 **AGPL-3.0 distribution.** As the sole copyright holder you may distribute + your own app on Play under Play's terms regardless of the AGPL (the AGPL binds + third parties, not you). Keep the source public (it is) so "Corresponding + Source" is available. If you ever accept outside AGPL contributions, require a + CLA to preserve this freedom and the dual-licensing model. +- 🌐 **Gemma / model licenses.** Gemma is under the + [Gemma Terms of Use](https://ai.google.dev/gemma/terms) and Prohibited Use + Policy, not an OSI license. The app downloads Gemma only when the user supplies + their own HF token and accepts the license on Hugging Face, so the user is the + licensee — but keep the in-app Gemma Terms link (done) and don't strip model + notices. + +## 2. Data safety form (Play Console → App content → Data safety) + +NativeLM collects/transmits **no** user data to us. Suggested answers: + +- **Does your app collect or share any of the required user data types?** → **No.** + (No analytics, ads, crash SDKs, or backend. Conversations/documents stay + on-device; the only network is user-initiated model downloads sent directly to + the model host.) +- **Is all user data encrypted in transit?** → Not applicable to data *we* + collect (none). Model downloads use HTTPS; local P2P sync payloads are + AES-256-GCM encrypted. +- **Do you provide a way to request data deletion?** → Data never leaves the + device; users delete everything via **Settings → Clear all data** or uninstall. +- Declare the **microphone** usage truthfully: audio is processed on-device for + voice input and not sent off device. + +> Re-check this form whenever you add a dependency — a new SDK can silently +> introduce data collection. + +## 3. Permissions justification + +The app declares only: + +- `INTERNET`, `ACCESS_NETWORK_STATE` — model downloads. +- `RECORD_AUDIO` — on-device voice input (Whisper). Provide a prominent in-app + rationale before first mic use; declare on the Data Safety form. +- `CHANGE_WIFI_MULTICAST_STATE` — local peer-to-peer sync discovery (mDNS). + +No `QUERY_ALL_PACKAGES`, no location, no broad storage. If Play flags any +permission, the justification above is the answer. + +## 4. Build & signing + +- 🛠️ **Build an Android App Bundle (AAB), not an APK** — Play requires AAB: + ``` + ./gradlew :sample-app:bundleRelease + # output: sample-app/build/outputs/bundle/release/sample-app-release.aab + ``` +- 🛠️ **Release signing** is wired via `sample-app/keystore.properties` + (gitignored). Create it before a real release: + ```properties + storeFile=/absolute/path/to/release.keystore + storePassword=… + keyAlias=… + keyPassword=… + ``` + Without it the release build falls back to debug signing (fine for local, **not** + for Play). Enroll in **Play App Signing**. +- ✅ **R8 minify + resource shrink** enabled on release; engine + native libs ship + consumer ProGuard rules. +- ✅ **64-bit only** (`arm64-v8a`) — meets Play's 64-bit requirement. (x86/emulator + not shipped; acceptable, but the Play pre-launch report's x86 devices will skip.) +- 🛠️ **Versioning** — bump `versionCode` for every upload (currently `6`) and set a + user-facing `versionName`. +- 🛠️ **`targetSdk`** must stay within Play's current window (one year of the latest + API). Verify before each submission. + +## 5. Network / security + +- ✅ **Cleartext disabled** — `usesCleartextTraffic="false"` + + `network_security_config.xml` (`cleartextTrafficPermitted=false`). Model + downloads are HTTPS; local P2P sync uses raw sockets carrying AES-GCM + ciphertext, unaffected by this policy. +- ✅ **`allowBackup="false"`** — no auto cloud backup of on-device data. + +## 6. Store listing (Play Console) + +- 🌐 App name, short & full description (lead with: private, on-device, no + account, no telemetry). +- 🌐 **Screenshots** — phone (and 7"/10" tablet if you target tablets); assets + exist under [`docs/screenshots/`](docs/screenshots/). +- 🌐 **App icon** (512×512) and **feature graphic** (1024×500). Adaptive launcher + icon is in the app. +- 🌐 **Content rating** questionnaire (IARC). +- 🌐 **Target audience & content** (not directed at children). +- 🌐 **Category**: Productivity / Tools. + +## 7. Device experience & first run + +- ✅ **No-account first run** — the catalog leads with **ungated** models + (Qwen3 0.6B, DeepSeek-R1 1.5B, Phi-4 mini, Qwen3 4B) that download with no HF + token; the app recommends the best one that fits the device's RAM. The gated + Gemma tier is behind the "Advanced" section. A reviewer or new user can reach a + working model with no Hugging Face account. +- ✅ **RAM-tier gating** — under-spec devices see "Not enough RAM" rather than an + OOM crash; downloads are SHA-validated and resumable. +- 🌐 Consider setting **device catalog / minimum RAM** expectations in the + listing so very low-RAM devices don't install and 1-star the app. +- 🌐 Run the **pre-launch report** and triage crashes on the low-end matrix. + +## 8. Pre-submission testing + +- 🛠️ Install and smoke-test the **release** AAB via a Play **internal testing** + track (not just `assembleDebug`) — R8 can change behavior. +- 🛠️ Verify: first-run downloads an ungated model with no token; gated download + shows the license reminder; voice input works; P2P sync works; "Clear all + data" wipes everything. + +## 9. Recommended follow-ups (not blocking this release) + +- ⏭️ **Foreground-service / WorkManager downloads.** Multi-GB model downloads + can be killed if the app is backgrounded mid-download. Wrap downloads in a + foreground service (with a notification) or WorkManager so they survive + backgrounding. Tracked separately from this PR. +- ⏭️ **Pin model revisions + SHA-256.** Catalog LLM URLs use Hugging Face + `resolve/main`, which can move; pin a revision and add `sha256` (as the Whisper + entry already does) for reproducible, tamper-evident downloads. +- ⏭️ **Wi-Fi-only download default** + clear data-usage messaging for large + models on metered connections. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..7d60c02 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,108 @@ +# NativeLM — Privacy Policy + +_Last updated: 2026-06-04_ + +NativeLM is a fully on-device AI app. It is built to do as much as possible +**without a network, without an account, and without sending your data anywhere.** +This policy explains exactly what that means. + +> Plain-language summary: **We don't collect anything.** Your conversations, +> documents, and settings stay on your device. The app has no analytics, no +> ads, no account, and no backend server operated by us. The only network the +> app makes is downloading AI models you explicitly choose, directly from the +> model host (e.g. Hugging Face / Google). + +## Who this applies to + +This policy covers the **NativeLM** Android app (`com.nativelm.app`), published +as the reference application for the open-source +[litertlm-kmp](https://github.com/sagar-develop/litertlm-kmp) engine. + +## What we collect + +**Nothing.** NativeLM has no analytics SDK, no crash-reporting SDK, no +advertising SDK, and no telemetry. We, the developers, do not receive your +prompts, your model responses, your documents, your usage, your device +identifiers, or any diagnostics. + +To make the zero-telemetry stance concrete, the app actively **removes** the +Google `datatransport` upload pipeline that the on-device OCR library (ML Kit) +would otherwise bundle, so it cannot initialize or upload anything. See the +`tools:node="remove"` entries in the app manifest. + +## Data stored on your device + +All of the following is stored **only on your device** and is never uploaded by us: + +| Data | Where it lives | Notes | +|---|---|---| +| Conversations & messages | On-device database (ObjectBox) | Deletable per-conversation or via "Clear all data". | +| Projects, documents & vector index | On-device database + app file storage | Imported PDFs/text are processed locally for retrieval. | +| Studio artifacts | On-device database | Generated locally from your sources. | +| App settings (theme, language, app-lock, onboarding) | On-device preferences (DataStore) | — | +| Hugging Face token (optional) | Encrypted on-device (`EncryptedSharedPreferences`) | Only used to authenticate downloads of gated models. Never sent to us. | +| Downloaded model files | App file storage | Deletable from the Models screen. | + +You can erase all of the above at any time from **Settings → Clear all data**, +or by uninstalling the app. + +## Network access + +The app makes network connections in only these cases, all initiated by you: + +1. **Model downloads.** When you tap to download a model, the app connects + **directly to the model host** (e.g. `huggingface.co` / Google storage CDNs) + over HTTPS to fetch the file. For license-gated models (e.g. Gemma), your + Hugging Face token is sent **to Hugging Face only**, as the standard + `Authorization` header, to authorize that download. The app does not route + these downloads through any server we operate. +2. **Local peer-to-peer sync (optional).** If you use device-to-device sync, + your data is transferred **directly between your own devices over your local + Wi-Fi network** (no internet, no server). The transferred bundle is + end-to-end encrypted (AES-256-GCM) with a one-time code shown on the sending + device. + +The app uses no cleartext HTTP for these connections, and declares +`cleartextTrafficPermitted="false"`. + +When third parties (such as Hugging Face or Google) serve a model download to +you, their own privacy policies govern that interaction. We have no visibility +into it. + +## Permissions and why they're used + +- **Internet / network state** — to download models you choose. +- **Microphone (`RECORD_AUDIO`)** — only when you tap the mic for voice input. + Audio is transcribed **on-device** (Whisper) and is never uploaded; recorded + audio is used transiently for transcription and not retained as a file by us. +- **Wi-Fi multicast (`CHANGE_WIFI_MULTICAST_STATE`)** — only for discovering + your other device during local peer-to-peer sync. + +The app requests no location, contacts, camera, or storage-wide permissions. + +## Children's privacy + +NativeLM is not directed at children and collects no personal information from +anyone, including children. + +## Third-party / open-source components + +NativeLM is open source under AGPL-3.0; the full source is at +. AI models you download are +provided by third parties under their own licenses and terms (for example, +Google's [Gemma Terms of Use](https://ai.google.dev/gemma/terms)). You are +responsible for complying with the license of any model you download. + +## Changes to this policy + +If this policy changes, the updated version will be published at this URL with a +new "Last updated" date. + +> Hosted copy: this policy is published via GitHub Pages at +> (the source of the +> hosted page is [`docs/privacy/index.html`](docs/privacy/index.html); this +> Markdown file is the canonical text). + +## Contact + +Questions about this policy: **sgupta8874@gmail.com** diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/EMBEDDING_GEMMA_PLAN.md b/docs/EMBEDDING_GEMMA_PLAN.md new file mode 100644 index 0000000..d592edf --- /dev/null +++ b/docs/EMBEDDING_GEMMA_PLAN.md @@ -0,0 +1,220 @@ +# NativeLM — EmbeddingGemma on-device RAG embedder (implementation plan) + +_Branch: `claude/analysis-KV4t2`. Goal: replace the 2018-era Universal Sentence +Encoder (USE-Lite, 100-dim) with **EmbeddingGemma 300M** as the default RAG +embedder, lifting retrieval quality for both chat answers and every Studio +artifact. USE-Lite stays as the low-end / no-download fallback._ + +## Locked decisions + +| Decision | Choice | Why | +|---|---|---| +| **Runtime** | **ONNX Runtime (Android)** + a SentencePiece/HF tokenizer | Self-contained; full control of task-prompts, pooling, normalization, Matryoshka. **No Google telemetry deps** (protects the zero-telemetry stance — commit `d5b5fa9`). KMP/iOS-friendly. Avoids the MediaPipe `TextEmbedder` path that broke before. | +| **Dimension** | **256** (Matryoshka truncation of the 768-native vector) | Best quality/size/speed balance on-device; the migration path already reserved in `DocumentChunkEntity`. ~2.5× storage vs the current 100-dim but far better retrieval; half the index cost of 512. | +| **Rollout** | **Default on capable devices; USE-Lite stays as fallback** | Friction-free first run preserved. Low-end / no-download installs keep working on USE-Lite. Two HNSW indexes coexist; the active embedder selects which one. | + +--- + +## Why the earlier attempt failed (recorded so we don't repeat it) + +EmbeddingGemma is a 300M transformer, **not** a TFLite *Task* model. Three +independent landmines, any one of which sinks a naive swap: + +1. **Wrong loader.** `MediaPipeEmbeddingEngine` calls + `TextEmbedder.createFromFile()`, which only accepts TFLite Task models with + baked-in tokenizer metadata (USE/BERT-style). EmbeddingGemma won't load there + (or returns garbage). **→ This plan introduces a separate ONNX engine; it does + not touch the MediaPipe path.** +2. **Dimension lock.** `DocumentChunkEntity.@HnswIndex(dimensions = 100L)` is an + annotation **literal**. EmbeddingGemma emits 768/512/256 → ObjectBox throws the + moment a longer vector is inserted/queried. **→ This plan adds a new 256-dim + entity rather than editing the 100-dim one.** +3. **Missing task prompts.** EmbeddingGemma *requires* instruction prefixes; + `EmbeddingEngine.embed(text)` is symmetric and `DefaultDocumentRetriever` calls + `embed(query)` with no role. Even if it loaded, retrieval would look "broken." + **→ This plan makes the interface task-aware.** + +--- + +## Architecture + +``` + ┌─ EmbeddingTask.QUERY → "task: search result | query: {q}" +query / chunk text ──► │ │ + └─ EmbeddingTask.DOCUMENT → "title: {t|none} | text: {c}"│ + ▼ + ┌──────────────────────────────────────┐ + │ OnnxEmbeddingEngine (androidMain) │ + │ tokenize (SentencePiece/HF) │ + │ → ORT run → last_hidden_state │ + │ → mean-pool over attention mask │ + │ → truncate to 256 (Matryoshka) │ + │ → L2 normalize │ + └──────────────────────────────────────┘ + │ 256-dim FloatArray + ▼ + active embedder selects index ──► GemmaChunkEntity (256-dim HNSW) [default] + DocumentChunkEntity (100-dim HNSW) [USE fallback / legacy] +``` + +The **active embedder** is an install-level property (which EMBEDDING model is +downloaded + chosen). It determines (a) which engine `embed*` routes to and +(b) which HNSW entity ingestion/retrieval use. Switching embedders triggers a +**re-index from stored chunk text** (no re-extraction needed). + +--- + +## Interface contract (lands first — Module 0) + +```kotlin +// lib/commonMain — EmbeddingEngine.kt (BREAKING: task-aware) +enum class EmbeddingTask { QUERY, DOCUMENT } + +interface EmbeddingEngine { + /** Output dimension of this embedder (USE-Lite = 100, EmbeddingGemma = 256). */ + val dimensions: Int + suspend fun initialize(modelPath: String) + /** [title] is only used for DOCUMENT task on prompt-instructed models; ignored otherwise. */ + suspend fun embed(text: String, task: EmbeddingTask, title: String? = null): FloatArray +} +``` + +```kotlin +// lib/commonMain — ModelCatalog.kt +enum class ModelFormat { LITERTLM, MEDIAPIPE_TEXT_EMBEDDER, WHISPER_GGML, ONNX_EMBEDDER } + +// ModelDescriptor gains companion-file support so the tokenizer ships with the model: +data class ModelDescriptor( + /* …existing… */ + val companions: List = emptyList(), // NEW — e.g. tokenizer.json +) +data class CompanionFile(val url: String, val fileName: String, val sizeBytes: Long, val sha256: String? = null) +``` + +```kotlin +// sample-app data.db — new 256-dim chunk entity (parallel to DocumentChunkEntity) +@Entity +class GemmaChunkEntity { + @Id var id: Long = 0 + @Index var documentId: Long = 0 + @Index var projectId: Long = 0 + var text: String = ""; var pageNumber: Int = 0; var chunkIndex: Int = 0 + @HnswIndex(dimensions = 256L, distanceType = VectorDistanceType.COSINE, + neighborsPerNode = 48, indexingSearchCount = 200) + var embedding: FloatArray? = null + companion object { const val EMBEDDING_DIM = 256 } +} +``` + +The `DocumentRepository` ingestion/retrieval methods route to the entity matching +the active embedder; `ScoredChunk` stays the common return shape so +`DefaultDocumentRetriever` / `RagContextFormatter` are largely unchanged. + +--- + +## Module breakdown + +| Mod | Scope | Key files | Depends on | +|----|-------|-----------|-----------| +| **0** | Contracts: task-aware `EmbeddingEngine`, `ONNX_EMBEDDER` format, `companions` on `ModelDescriptor`, `GemmaChunkEntity` (regen `objectbox-models/default.json`) | `EmbeddingEngine.kt`, `ModelCatalog.kt`, `Entities.kt`, version catalog | — | +| **A** | ONNX engine: ORT session, tokenizer, mean-pool + Matryoshka-256 + L2-norm, task prompts | `OnnxEmbeddingEngine.kt` (androidMain), DI wiring in `AndroidAiEngineComponent.kt` | 0 | +| **B** | Catalog + download: EmbeddingGemma descriptor (`requiresAuth = true`), tokenizer companion download, sha256 pins | `NativeLmModelCatalog.kt`, model-download path | 0 | +| **C** | Repository routing: `GemmaChunkEntity` CRUD + HNSW search; active-embedder selector | `ObjectBoxDocumentRepository.kt`, `RagHolder.kt` | 0 | +| **D** | Ingest/retrieve task wiring: `embedDocument` on ingest, `embedQuery` on retrieve; re-tune distance gate | `DefaultDocumentIngestor.kt`, `DefaultDocumentRetriever.kt` | A,C | +| **E** | Migration: background re-index USE→Gemma from stored text, with progress + resume | new `EmbeddingMigrator.kt`, `NativeLmViewModel.kt` | C,D | +| **F** | UI + gating: embedder shown in Models screen (Recommended/Advanced, Gemma terms), device gating, re-index progress | `ModelManagementScreen.kt`, onboarding terms gate | B,E | +| **G** | Backup/sync compatibility: carry embedder tag; re-index on mismatched import | `BackupManager.kt`, `BackupModels.kt`, sync transport | E | + +--- + +## Migration / re-index plan + +Embeddings are **derived data**; `DocumentChunkEntity.text` is already persisted, +so re-indexing never needs the original PDFs. + +1. On first run after EmbeddingGemma is downloaded + selected, kick a background + `EmbeddingMigrator` (resumable, idempotent — skip docs already in `GemmaChunkEntity`). +2. Stream chunks per project → `embed(text, DOCUMENT, title)` → write to + `GemmaChunkEntity`. Reuse the `IngestState.Embedding(done,total)` progress UI. +3. Until a project is migrated, retrieval **falls back to the 100-dim index** so + chat keeps working. +4. After a project migrates, delete its old 100-dim chunks to reclaim storage + (tx-split delete — see gotchas). +5. Low-end devices that never download EmbeddingGemma stay entirely on USE-Lite. + +--- + +## Gotchas (bake these in) + +- **HNSW tx-split (carried over):** chunk deletes and parent-doc deletes go in + **separate transactions**, or HNSW commit deadlocks. Applies to the re-index + cleanup too. +- **Distance gate is USE-tuned.** `DefaultDocumentRetriever.RELEVANCE_MAX_DISTANCE + = 0.75` was tuned for USE-Lite's distribution. EmbeddingGemma's cosine spread + differs — **re-tune per active embedder** (likely a separate constant), or + off-topic queries will over/under-ground. +- **Task prompts are mandatory.** Query = `task: search result | query: …`; + Document = `title: {title or "none"} | text: …`. Wrong/missing prompts quietly + tank recall. +- **Matryoshka order: truncate *then* re-normalize.** Take the first 256 dims of + the pooled vector, *then* L2-normalize — not the reverse. +- **Tokenizer is the fiddly bit.** Ship `tokenizer.json` as a `companions` file + (or app asset) and run it via `onnxruntime-extensions` (in-graph) or the HF + `tokenizers` Android binding. Cap `max_seq_len` (~512) — chunks are ~500 chars + so this is safe and bounds latency/memory. +- **Latency.** A 300M transformer per chunk is far slower than USE's 6MB model; + a big PDF can go from seconds to minutes. Mitigate: quantized (INT8/QAT) ONNX, + XNNPACK threads, batch tokenization, and run ingestion/migration off the main + thread (ties into the deferred foreground-service download/ingest work in + `PLAY_STORE.md §9`). +- **Memory coexistence.** Don't embed and generate simultaneously — the LLM is the + big RAM tenant. Sequence ingestion/migration vs active chat generation. +- **Gemma licensing.** EmbeddingGemma is Gemma-licensed → `requiresAuth = true`, + `Authorization: Bearer `, surfaced under the **Advanced — Hugging Face + account** section and the onboarding **terms gate** already built in PR #22. +- **Backup/sync dimension mismatch.** A backup/synced DB may carry vectors from a + different embedder/dimension. Tag exports with the embedder id; on import with a + mismatch, **re-index from the included chunk text** rather than trusting vectors. +- **APK/download budget.** ORT Android AAR (~10–20 MB, arm64-only to match the + existing `abiFilters`) + the quantized ONNX model (~100–200 MB, downloaded, not + bundled) + tokenizer. Confirm against the size budget. + +--- + +## Dependencies to add + +- `com.microsoft.onnxruntime:onnxruntime-android` (full build for op coverage; + revisit ORT-format + `onnxruntime-mobile` later for size). +- `com.microsoft.onnxruntime:onnxruntime-extensions-android` (in-graph tokenizer), + **or** the HF `tokenizers` Android binding as fallback. +- arm64-v8a only, consistent with `libwhisper.so` and the LiteRT-LM footprint. + +--- + +## Testing / verify (the ship bar) + +On-device on **CPH2723 (release build)**: +1. Fresh ingest of a real PDF → confirm chunks land in `GemmaChunkEntity` (256-dim). +2. **Retrieval quality A/B**: a fixed query set, USE-Lite vs EmbeddingGemma — the + win must be visible (recall on names/concepts, fewer off-topic citations). +3. **Latency/memory**: per-chunk embed time + peak RAM during ingest; confirm a + multi-page PDF completes acceptably and coexists with chat. +4. **Migration**: upgrade an install with existing USE-Lite docs → re-index runs, + shows progress, retrieval keeps working throughout, old vectors reclaimed after. +5. **Low-end fallback**: a device that declines the download stays on USE-Lite and + functions unchanged. +6. **Distance-gate tuning**: verify off-topic questions still return + `RetrievedContext.EMPTY` with the re-tuned threshold. + +**Done when:** EmbeddingGemma is the default embedder on a capable device, an +existing install migrates cleanly, retrieval quality is visibly better, and the +USE-Lite fallback path still works end-to-end. + +--- + +## Out of scope (follow-ups) + +- **Reranker** second stage (cross-encoder) — separate plan; complements this. +- **ORT-format / mobile build** size optimization — after correctness is proven. +- **iOS embedder** — the ONNX engine is KMP-portable; wire `iosMain` later. +- **Token-aware chunking** — still char-based (500/50); revisit independently. diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..035edea --- /dev/null +++ b/docs/index.html @@ -0,0 +1,45 @@ + + + + + + NativeLM — private, on-device AI + + + + +
+

NativeLM

+

A private, fully on-device AI app for Android. No account, no + servers, no telemetry — your documents and conversations never leave the device.

+ +
Built on the open-source litertlm-kmp on-device LLM engine.
+
+ + diff --git a/docs/privacy/index.html b/docs/privacy/index.html new file mode 100644 index 0000000..a279dbf --- /dev/null +++ b/docs/privacy/index.html @@ -0,0 +1,146 @@ + + + + + + NativeLM — Privacy Policy + + + + +
+

NativeLM — Privacy Policy

+

Last updated: 2026-06-04

+ +

NativeLM is a fully on-device AI app. It is built to do as much as possible + without a network, without an account, and without sending your data + anywhere. This policy explains exactly what that means.

+ +
+ Plain-language summary: We don't collect anything. Your + conversations, documents, and settings stay on your device. The app has no + analytics, no ads, no account, and no backend server operated by us. The only + network the app makes is downloading AI models you explicitly choose, directly + from the model host (e.g. Hugging Face / Google). +
+ +

Who this applies to

+

This policy covers the NativeLM Android app + (com.nativelm.app), published as the reference application for the + open-source + litertlm-kmp engine.

+ +

What we collect

+

Nothing. NativeLM has no analytics SDK, no crash-reporting + SDK, no advertising SDK, and no telemetry. We, the developers, do not receive + your prompts, your model responses, your documents, your usage, your device + identifiers, or any diagnostics.

+

To make the zero-telemetry stance concrete, the app actively + removes the Google datatransport upload pipeline + that the on-device OCR library (ML Kit) would otherwise bundle, so it cannot + initialize or upload anything.

+ +

Data stored on your device

+

All of the following is stored only on your device and is + never uploaded by us:

+ + + + + + + + +
DataWhere it livesNotes
Conversations & messagesOn-device database (ObjectBox)Deletable per-conversation or via "Clear all data".
Projects, documents & vector indexOn-device database + app file storageImported PDFs/text are processed locally for retrieval.
Studio artifactsOn-device databaseGenerated locally from your sources.
App settingsOn-device preferences (DataStore)Theme, language, app-lock, onboarding.
Hugging Face token (optional)Encrypted on-deviceOnly used to authenticate downloads of gated models. Never sent to us.
Downloaded model filesApp file storageDeletable from the Models screen.
+

You can erase all of the above at any time from Settings → Clear all + data, or by uninstalling the app.

+ +

Network access

+

The app makes network connections in only these cases, all initiated by you:

+
    +
  1. Model downloads. When you tap to download a model, the app + connects directly to the model host (e.g. + huggingface.co / Google storage CDNs) over HTTPS. For + license-gated models (e.g. Gemma), your Hugging Face token is sent + to Hugging Face only, as the standard Authorization + header, to authorize that download. Downloads are not routed through any server + we operate.
  2. +
  3. Local peer-to-peer sync (optional). Your data is + transferred directly between your own devices over your local Wi-Fi + network (no internet, no server). The transferred bundle is end-to-end + encrypted (AES-256-GCM) with a one-time code shown on the sending device.
  4. +
+

The app uses no cleartext HTTP for these connections + (cleartextTrafficPermitted=false). When third parties (such as + Hugging Face or Google) serve a download to you, their own privacy policies + govern that interaction.

+ +

Permissions and why they're used

+
    +
  • Internet / network state — to download models you choose.
  • +
  • Microphone (RECORD_AUDIO) — only when you tap + the mic for voice input. Audio is transcribed on-device + (Whisper) and is never uploaded; recorded audio is used transiently for + transcription and not retained as a file by us.
  • +
  • Wi-Fi multicast — only to discover your other device during + local peer-to-peer sync.
  • +
+

The app requests no location, contacts, camera, or storage-wide permissions.

+ +

Children's privacy

+

NativeLM is not directed at children and collects no personal information from + anyone, including children.

+ +

Third-party / open-source components

+

NativeLM is open source under AGPL-3.0; the full source is at + github.com/sagar-develop/litertlm-kmp. + AI models you download are provided by third parties under their own licenses and + terms (for example, Google's + Gemma Terms of Use). You are + responsible for complying with the license of any model you download.

+ +

Changes to this policy

+

If this policy changes, the updated version will be published at this URL with a + new "Last updated" date.

+ +

Contact

+

Questions about this policy: + sgupta8874@gmail.com

+ + +
+ + diff --git a/docs/prototype/DESIGN.md b/docs/prototype/DESIGN.md new file mode 100644 index 0000000..6b806e3 --- /dev/null +++ b/docs/prototype/DESIGN.md @@ -0,0 +1,135 @@ +# NativeLM — Adaptive UI Redesign + +A premium‑minimal refresh of the existing identity, plus a full multi‑device +(phone / foldable / tablet) adaptive layout. Nothing about the brand changes — +this **elevates** what's already locked in `Color.kt` / `Type.kt`. + +> **Visualize it:** open [`index.html`](./index.html) in any browser. Use the +> top bar to switch **Device** (Phone · Foldable · Tablet) and **Theme** +> (Light · Dark), and the left list to jump between screens. The same screen +> reflows across breakpoints — that *is* the adaptive system. + +This is a design artifact for sign‑off. Once approved it gets implemented in +Compose (Material3 + `androidx.compose.material3.adaptive`). + +--- + +## 1. Design language (elevated, not replaced) + +| Token | Light | Dark | Notes | +|---|---|---|---| +| Brand / primary | `#7FA980` | `#7FA980` | the one sage accent, used sparingly | +| Canvas (`background`) | `#FAF9F6` | `#1C1B1A` | warm | +| `surface` | `#FFFFFF` | `#232220` | cards | +| `surfaceVariant` | `#EFEDE8` | `#2C2A27` | fills, chips | +| `onBackground` | `#1C1B1A` | `#ECEAE4` | | +| `onSurfaceVariant` | `#6B6862` | `#A8A49C` | secondary text | +| `outline` | `#DAD8D2` | `#3A3833` | hairlines | +| `primaryContainer` | `#E9F0E9` | `#2E3B2E` | user bubble, selected, active badge | +| `error` | `#BA4A42` | `#E5938B` | restrained | + +What "elevated" means concretely: + +- **Hierarchy** — tighter, more deliberate type scale; titles get weight, not size. +- **Spacing** — strict **8pt system** (4/8/12/16/20/24/32/40/48). +- **Surfaces** — soft warm elevation (3 levels), `1px` hairline borders on cards + instead of heavy shadows. Radii: `sm 10 · md 14 · lg 20 · xl 28 · pill`. +- **Mono discipline** — JetBrains Mono stays reserved for technical metadata + (file names, sizes, `tok/s`, RAM, page numbers, version, license, codes). +- **Motion** — `cubic-bezier(.2,.7,.2,1)`; 150–180ms for state, ~320–420ms for + layout/drawer transitions. Subtle, never bouncy. +- **Empty/loading states** — every list has a considered empty state and an + inline progress affordance (see Sources, Studio, Models). + +--- + +## 2. Breakpoints & navigation + +Following Material 3 adaptive window size classes. + +| Class | Width | Nav | Layout | +|---|---|---|---| +| **Compact** (phone) | `< 600dp` | Top bar **hamburger** → modal drawer; single pane | one screen at a time | +| **Medium** (foldable / small tablet) | `600–839dp` | **Navigation rail** (left, icon+label) | single detail pane, wider content with max‑width | +| **Expanded** (tablet / unfolded) | `≥ 840dp` | Navigation rail **+** persistent **list pane** | **list‑detail two‑pane** | + +Destinations are identical across all sizes: **Chat · Models · Sources · +Studio · Settings**. Only their *presentation* changes (drawer item → rail +item → rail item). Content panes cap at ~720–760dp and center, so text never +runs full‑width on a tablet. + +Compose mapping: +- `currentWindowAdaptiveInfo()` → `WindowWidthSizeClass`. +- `NavigationSuiteScaffold` for the Chat/Models/Sources/Studio/Settings shell + (auto drawer ↔ rail ↔ rail). +- `ListDetailPaneScaffold` for the two‑pane screens below. + +--- + +## 3. Per‑screen adaptive behavior + +### Chat (the hub) +- **Compact:** top bar (`☰` · title + `model · on‑device` · `EN` · Studio · + New chat) → message thread → composer. Conversations/projects live in the + **modal drawer**. +- **Medium:** rail replaces the hamburger; thread + composer fill the pane. +- **Expanded:** **two‑pane** — left *Conversations* list (search, chats, + Projects, New chat) │ right *thread*. Selecting a chat updates the detail + pane in place; no navigation. Composer pinned to the detail pane. +- Message design: role label (`YOU` / `NATIVELM`), user bubble in + `primaryContainer`, assistant rendered as markdown (code in mono), streaming + = 3‑dot pulse, **Sources (N)** expander with citation chips + (`title · p.N`) that open the PDF viewer. + +### Models +- Single scroll; on Medium/Expanded the content column caps + centers (cards + don't stretch). Sections: **Recommended → Document/RAG → Audio → Advanced + (Hugging Face)** with the collapsible token card. Sticky **Continue to chat** + bottom bar when a model is active. Card = name + `Recommended`/`Active` badge, + mono metadata line, modality chips (`Text`/`Image`), inline download progress. + +### Sources (Documents) +- Compact: list with full‑width **Import** button and inline import‑status card. +- Expanded: this becomes the **list pane** companion to a document preview + (Sources list │ PDF/page preview), so adding/reading sources is one view. + +### Studio +- Responsive grid: **2 columns** (compact) → **3 columns** (medium/expanded) + for the Create cards. Audio hero cards full‑width. Outputs list below. +- Expanded: artifact **list │ artifact viewer** two‑pane (pick an output on the + left, read it on the right) — FAQ / Key Topics / Study Guide / Timeline / + Mind Map / Audio / Podcast viewers as specified today. + +### Settings +- Grouped cards (Appearance / Models / Language / Security / Data & backup / + About). Content column caps + centers on wide screens. Segmented Theme + control, switches, value rows, mono for Version/License. Footer: + `No telemetry · No account · No upload`. + +### PDF viewer +- Cited‑passage callout (`primaryContainer`) → zoom/pan page → page bar + (`Page N of M`, mono). On Expanded it can dock beside the Sources list. + +### Flows (full‑bleed, no nav shell) +- **Onboarding** (4 slides, dots, Skip/Next → Get started + terms), + **Splash** (mark + tagline + indeterminate progress + privacy line), + **Lock** (lock glyph, "NativeLM is locked", Unlock). These ignore the + adaptive shell and center their content with comfortable max‑width on tablets. + +--- + +## 4. Implementation plan (after sign‑off) + +1. **Adaptive shell** — introduce `NavigationSuiteScaffold` around the five + primary destinations; keep existing `NavController` routes for flows + (splash/onboarding/lock/pdf). +2. **Two‑pane** — `ListDetailPaneScaffold` for Chat, Sources, Studio on + Expanded; collapse to single pane on Compact/Medium. +3. **Theme polish** — formalize spacing/elevation/radius tokens; audit each + screen against the prototype. +4. **Screen passes** — Chat → Models → Settings → Sources → Studio → PDF → + flows, staged commits. + +Dependencies: `androidx.compose.material3:material3-adaptive*` (navigation‑suite ++ adaptive‑layout). No new fonts, colors, or network — the zero‑network / +on‑device promise is untouched. diff --git a/docs/prototype/index.html b/docs/prototype/index.html new file mode 100644 index 0000000..679585e --- /dev/null +++ b/docs/prototype/index.html @@ -0,0 +1,941 @@ + + + + + + +NativeLM — Adaptive UI Prototype + + + + + +
+
+ + + + + NativeLM · adaptive UI prototype +
+
+
Device +
+ + + +
+
+
Theme +
+ + +
+
+
+ +
+ + + + +
+
+
+
+ + + + + +
+ +
+
Conversations +
+
$ICON_search Search chats
+
+
$ICON_chatlineRefactoring the sync layer
+
$ICON_chatlineSummarize lease agreement
+
$ICON_chatlineTrip packing checklist
+
$ICON_chatlineExplain transformer attention
+ +
$ICON_folderQ3 Research
+
$ICON_folderApartment paperwork
+
+
$ICON_plus New chat
+
+ +
+
+ +
Refactoring the sync layer
gemma-3n-4b · on-device
+ + + +
+
+
+
+
You
+
How should I structure the peer-to-peer sync so a transfer can resume if the Wi-Fi drops mid-way?
+
NativeLM
+
+

Make the transfer chunked and idempotent so either side can reconnect and continue from the last acknowledged offset:

+

1. Split the encrypted bundle into fixed 64 KiB chunks, each with an index and a SHA-256.
+ 2. The receiver persists a small manifest of chunks it already has.
+ 3. On reconnect, it requests only the missing indices.

+
data class Chunk(
+  val index: Int,
+  val sha256: String,
+  val bytes: ByteArray,
+)
+

Because each chunk is content-addressed, replaying a chunk is harmless — that's what makes resume safe.

+
+
$ICON_doc Sources (2) $ICON_chevdown
+
$ICON_docSocketTransfer — design notesp.3
+
$ICON_docP2P sync RFC (internal)p.12
+
+
+
You
+
Nice. Show me what the resume handshake looks like.
+
NativeLM
+
+
+
+
+
+ + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ +
Models
+
+
+ +
Free, open models — no account needed. Downloads start right away.
+ +
+
+
Llama 3.2 3B InstructRecommended
+
llama-3.2-3b-instruct.gguf · 1.92 GB
+ +
+
Text
+
+ +
+
+
Gemma 3n 4B +
gemma-3n-4b.task · 4.40 GB · needs 6 GB RAM
+ Active +
+
TextImage
+
+ +
+
+
Qwen2.5 1.5B Instruct +
qwen2.5-1.5b-instruct.gguf · 986 MB
+ +
+
Text
+
+
611 / 986 MB
+
+ + +
for document chat (Projects)
+
+
BGE Small EN v1.5 +
bge-small-en-v1.5.gguf · 33.6 MB · needs 1 GB RAM
+ Active +
+ + +
Gemma models. Free, but need a Hugging Face account + token and a one-time license accept.
+
+ HuggingFace token +
Used to download license-gated models directly from Hugging Face.
+
+ hf_•••••••••••• +
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
Sources
Q3 Research
+
+
+
Sources stay on your device. This project's chat answers from them.
+ + +
+
+ Indexing 18 / 42… +
+ +
+
$ICON_doc +
Residential Lease Agreement.pdf
+
14 pages · 38 sections
+ +
+
+
$ICON_doc +
Q3 market notes.txt
+
9 sections
+ +
+
+
$ICON_doc +
Whiteboard photo.jpg
+
3 sections
+ +
+
+
+
+ + +
+
+
+ +
Studio
Q3 Research
+
+
+
Generate overviews from this project's sources. Everything runs on your device.
+ + +
+
$ICON_audio
+
Audio Overview
A spoken summary, single narrator
+
$ICON_play
+
+
+
$ICON_podcast
+
Podcast
Two hosts discuss your sources
+
$ICON_play
+
+ + +
+
$ICON_doc
Briefing
A concise overview
+
$ICON_faq
FAQ
Questions & answers
+
$ICON_topics
Key Topics
The main themes
+
$ICON_school
Study Guide
Terms & review
+
$ICON_timeline
Timeline
Events in order
+
$ICON_mindmap
Mind Map
A visual tree
+
+ + +
+
$ICON_timeline +
Lease timeline
Timeline · Whole project · 2h ago
+ +
+
+
$ICON_faq +
Tenant rights FAQ
FAQ · Lease Agreement.pdf · yesterday
+ +
+ + +
+
+
Day 0 — Move-in
Inspection checklist signed; deposit of two months held in escrow.
+
Month 6
Mid-term review window; either party may give notice.
+
Month 11
Renewal notice due 30 days before term end.
+
Month 12 — Term end
Deposit returned within 21 days, less itemized deductions.
+
+
+
+
+
+ + +
+
+
+ +
Settings
+
+
+ +
+
Theme
+
+
+ + +
+
Manage models
$ICON_chevright
+
+
Active model
gemma-3n-4b
+
+ + +
+
Answer language
The AI answers in this language, even over English documents.
English
+
+ + +
+
App lock
Require fingerprint, face, or your screen lock to open NativeLM.
+
+
+ + +
+
Back up my data
Save an encrypted copy you control. Nothing is uploaded.
$ICON_chevright
+
+
Restore from backup
Import a .nlmbak file. Restored data is added to what's already here.
$ICON_chevright
+
+
Send to a nearby device
Beam your data over Wi-Fi. Nothing leaves the network.
$ICON_chevright
+
+
Clear all data
Deletes downloaded models and resets the app.
$ICON_chevright
+
+ + +
+
Version
0.8.0
+
+
License
AGPL-3.0
+
+
Privacy policy
$ICON_chevright
+
+
No telemetry · No account · No upload
+
+
+
+ + +
+
+
+ +
Residential Lease Agreement.pdf
+
+
+
Cited passage
+
The security deposit shall be returned within twenty-one (21) days of the termination of tenancy, less any itemized deductions.
+
+
+
RESIDENTIAL LEASE AGREEMENT
+

This Agreement is entered into between the Landlord and the Tenant for the premises described herein. The term of this lease shall be twelve (12) months commencing on the date of occupancy.

+

Section 7 — Security Deposit. The security deposit shall be returned within twenty-one (21) days of the termination of tenancy, less any itemized deductions for damages beyond ordinary wear and tear.

+

Section 8 — Renewal. Either party may elect to renew this Agreement by providing written notice no fewer than thirty (30) days prior to the end of the term.

+

Section 9 — Maintenance. The Tenant shall maintain the premises in clean and sanitary condition throughout the tenancy.

+
+
+
$ICON_chevleftPage 3 of 14$ICON_chevright
+
+
+
+ +
+ + +
+
$ICON_shield_lg
+

Private by design

+

NativeLM runs entirely on your phone. No account, no servers, no telemetry — your documents and conversations never leave the device.

+
+ +
Open source (AGPL-3.0). Downloaded models are subject to their own licenses.
+ Source code · Gemma Terms
+
+ +
+ +

NativeLM

+

Your documents and conversations never leave your phone.

+
+
On-device · No account · No telemetry
+
+ +
+
$ICON_lock_lg
+

NativeLM is locked

+

Your documents and chats stay on this device. Unlock to continue.

+ +
+ +
+
390 × 780 · compact
+
+
+
+ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4025c45..c4804cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,12 @@ compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-material3 = { module = "androidx.compose.material3:material3" } +# Adaptive (multi-device) UI — all BOM-managed (compose-bom 2024.11.00 pins material3-adaptive 1.0.0). +compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } +compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } diff --git a/sample-app/README.md b/sample-app/README.md index 4a227de..9e40558 100644 --- a/sample-app/README.md +++ b/sample-app/README.md @@ -59,18 +59,24 @@ product. (not just the UI); start a fresh thread; long-press any bubble to copy. - **Persistence** — all conversations survive app restart. - **Model management** — download models on demand and switch the active model - from inside the app. No model is bundled in the APK. -- **Bring your own token** — gated models download using **your** Hugging Face - token, pasted into the app and stored encrypted on-device. Never Firebase, - never a backend of ours. + from inside the app. No model is bundled in the APK. The model screen leads + with a **Recommended** section of free, ungated models (no account needed) and + highlights the best one for your device's RAM; license-gated **Gemma** models + sit behind an **Advanced** section. +- **No account needed to start** — the recommended models (Qwen3, DeepSeek, Phi) + are Apache-2.0 / MIT and download with **no Hugging Face token**. +- **Bring your own token (advanced)** — gated Gemma models download using + **your** Hugging Face token, pasted into the app and stored encrypted + on-device. Never Firebase, never a backend of ours. Image input (multimodal chat) is on the roadmap; the engine already supports it (`descriptor.supportsVision`), it's just not surfaced in the chat UI yet. ## First run vs. later launches -- **First run:** Splash → Onboarding → Model Management (download an LLM with - your HF token, set it active) → Chat. +- **First run:** Splash → Onboarding → Model Management (download the + recommended free model — **no token needed** — or pick a gated Gemma model + under *Advanced* with your HF token; set it active) → Chat. - **Every later launch:** the previously-selected model **auto-loads from disk into memory** — no download, no network. Works fully offline. @@ -115,6 +121,15 @@ $env:JAVA_HOME = "C:\Users\shefa\AppData\Local\Programs\Android Studio\jbr" - Requires a device with **6 GB+ RAM** for the E2B model (10 GB+ for E4B). - Verified on Realme CPH2723 (Android 16, 8 GB). +## Publishing to Google Play + +A full release checklist lives in [`PLAY_STORE.md`](../PLAY_STORE.md) and the +privacy policy (hosted-ready) in [`PRIVACY.md`](../PRIVACY.md). In short: build an +**AAB** (`./gradlew :sample-app:bundleRelease`), supply `keystore.properties` for +release signing, host the privacy policy and fill the **Data Safety** form (the +app collects nothing), and submit. The app's no-account first run means a +reviewer can reach a working model without a Hugging Face account. + ## Design NativeLM does not share the engine README's dry/technical tone — it talks to end diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index f72f39b..866c16c 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -132,6 +132,12 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + // Adaptive (multi-device) UI — navigation-suite shell + list-detail two-pane. + implementation(libs.compose.material3.windowsizeclass) + implementation(libs.compose.material3.adaptive.navigation.suite) + implementation(libs.compose.material3.adaptive) + implementation(libs.compose.material3.adaptive.layout) + implementation(libs.compose.material3.adaptive.navigation) implementation(libs.compose.material.icons.extended) implementation(libs.compose.foundation) diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml index 7c8909e..52d6d0f 100644 --- a/sample-app/src/main/AndroidManifest.xml +++ b/sample-app/src/main/AndroidManifest.xml @@ -32,6 +32,8 @@ android:theme="@style/Theme.NativeLM" android:supportsRtl="true" android:allowBackup="false" + android:usesCleartextTraffic="false" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="33" xmlns:tools="http://schemas.android.com/tools"> diff --git a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt index dc64c13..98623c2 100644 --- a/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt +++ b/sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt @@ -311,6 +311,27 @@ class NativeLmViewModel(app: Application) : ViewModel() { .map { id -> id?.let { catalog.byId(it) }?.let(::displayName) } .stateIn(viewModelScope, SharingStarted.Eagerly, null) + /** + * The best "no account needed" model for this device: the largest **ungated** + * (Apache-2.0 / MIT) LLM whose RAM floor the device clears. Surfaced as the + * "Recommended" pick in Model Management so a fresh install — including a Play + * Store user with no Hugging Face account — can get a working model with a + * single tap: no token, no license to accept. Null only on devices below even + * the smallest model's RAM floor. Device RAM is fixed for the process, so this + * is computed once on first read. + */ + val recommendedModelId: String? by lazy { + catalog.byRole(ModelRole.LLM_PRIMARY) + .filter { !it.requiresAuth && deviceRamMb >= it.minDeviceRamMb } + .maxByOrNull { it.minDeviceRamMb } + ?.id + } + + /** One-tap convenience: download the device's recommended ungated model. No-op if none fits. */ + fun downloadRecommended() { + recommendedModelId?.let { download(it) } + } + private val downloadJobs = mutableMapOf() private var generationJob: Job? = null private var studioJob: Job? = null diff --git a/sample-app/src/main/java/com/nativelm/app/ui/AdaptiveShell.kt b/sample-app/src/main/java/com/nativelm/app/ui/AdaptiveShell.kt new file mode 100644 index 0000000..b6c17a4 --- /dev/null +++ b/sample-app/src/main/java/com/nativelm/app/ui/AdaptiveShell.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 Sagar Gupta + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nativelm.app.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.window.core.layout.WindowWidthSizeClass +import com.nativelm.app.llm.NativeLmViewModel +import com.nativelm.app.ui.chat.ChatScreen +import com.nativelm.app.ui.documents.DocumentsScreen +import com.nativelm.app.ui.models.ModelManagementScreen +import com.nativelm.app.ui.settings.SettingsScreen +import com.nativelm.app.ui.studio.StudioScreen + +/** The five top-level destinations of the adaptive shell (DESIGN.md §2). */ +enum class Destination(val label: String, val icon: ImageVector) { + Chat("Chat", Icons.Outlined.Forum), + Models("Models", Icons.Filled.Memory), + Sources("Sources", Icons.Outlined.Description), + Studio("Studio", Icons.Filled.AutoAwesome), + Settings("Settings", Icons.Filled.Settings), +} + +/** + * Adaptive navigation shell hosting the five primary destinations. The window + * width class drives the presentation (DESIGN.md §2): + * - **Compact** → no rail; each screen keeps its own top bar + Chat's modal + * drawer (the hub model, unchanged for phones). + * - **Medium** → a left navigation rail. + * - **Expanded** → a left navigation rail (+ two-pane inside Chat/Sources/Studio, + * handled per-screen). + * + * Flow routes (splash / onboarding / lock / pdf) live in [NativeLmApp]'s NavHost + * outside this shell; [onOpenPdf] bridges back out to the PDF viewer route. + */ +@Composable +fun AdaptiveShell( + vm: NativeLmViewModel, + initial: Destination, + onOpenPdf: () -> Unit, +) { + var selected by rememberSaveable { mutableStateOf(initial) } + + val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass + val isCompact = widthClass == WindowWidthSizeClass.COMPACT + val isExpanded = widthClass == WindowWidthSizeClass.EXPANDED + + // On compact there's no persistent rail, so a secondary destination needs a + // way back to Chat — honor the system back button instead of trapping the user. + if (isCompact && selected != Destination.Chat) { + BackHandler { selected = Destination.Chat } + } + + val layoutType = if (isCompact) NavigationSuiteType.None else NavigationSuiteType.NavigationRail + + NavigationSuiteScaffold( + navigationSuiteItems = { + Destination.entries.forEach { dest -> + item( + selected = dest == selected, + onClick = { selected = dest }, + icon = { Icon(dest.icon, contentDescription = dest.label) }, + label = { Text(dest.label) }, + ) + } + }, + layoutType = layoutType, + containerColor = MaterialTheme.colorScheme.background, + ) { + when (selected) { + Destination.Chat -> ChatScreen( + vm = vm, + showNavRail = !isCompact, + expanded = isExpanded, + onOpenModels = { selected = Destination.Models }, + onOpenSettings = { selected = Destination.Settings }, + onOpenDocuments = { selected = Destination.Sources }, + onOpenPdf = onOpenPdf, + onOpenStudio = { selected = Destination.Studio }, + ) + + Destination.Models -> ModelManagementScreen( + vm = vm, + // On the rail the destination is always reachable — no back arrow. + canGoBack = isCompact, + onBack = { selected = Destination.Chat }, + onContinue = { selected = Destination.Chat }, + ) + + Destination.Sources -> DocumentsScreen( + vm = vm, + showBack = isCompact, + onBack = { selected = Destination.Chat }, + ) + + Destination.Studio -> StudioScreen( + vm = vm, + showBack = isCompact, + onBack = { selected = Destination.Chat }, + onAskInChat = { selected = Destination.Chat }, + ) + + Destination.Settings -> SettingsScreen( + vm = vm, + showBack = isCompact, + onBack = { selected = Destination.Chat }, + onOpenModels = { selected = Destination.Models }, + ) + } + } +} diff --git a/sample-app/src/main/java/com/nativelm/app/ui/NativeLmApp.kt b/sample-app/src/main/java/com/nativelm/app/ui/NativeLmApp.kt index cf3e248..96a8c66 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/NativeLmApp.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/NativeLmApp.kt @@ -5,44 +5,55 @@ package com.nativelm.app.ui import androidx.compose.runtime.Composable +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.nativelm.app.llm.NativeLmViewModel -import com.nativelm.app.llm.ROUTE_CHAT -import com.nativelm.app.llm.ROUTE_DOCUMENTS -import com.nativelm.app.llm.ROUTE_MODELS import com.nativelm.app.llm.ROUTE_ONBOARDING import com.nativelm.app.llm.ROUTE_PDF_VIEWER -import com.nativelm.app.llm.ROUTE_SETTINGS import com.nativelm.app.llm.ROUTE_SPLASH -import com.nativelm.app.llm.ROUTE_STUDIO -import com.nativelm.app.ui.chat.ChatScreen -import com.nativelm.app.ui.documents.DocumentsScreen -import com.nativelm.app.ui.models.ModelManagementScreen import com.nativelm.app.ui.onboarding.OnboardingScreen import com.nativelm.app.ui.pdf.PdfViewerScreen -import com.nativelm.app.ui.settings.SettingsScreen import com.nativelm.app.ui.splash.SplashScreen -import com.nativelm.app.ui.studio.StudioScreen + +/** Shell route, carrying the initial destination as a path arg (`shell/chat`). */ +private const val ROUTE_SHELL = "shell" +private fun shellRoute(dest: Destination) = + "$ROUTE_SHELL/${if (dest == Destination.Models) "models" else "chat"}" /** - * NativeLM navigation graph. The start destination is decided by the ViewModel's - * boot logic (onboarding done? a model on disk?) and passed in as [startRoute]. + * NativeLM navigation graph. The full-bleed *flows* (splash / onboarding / PDF + * viewer) are NavHost routes; the five primary destinations live inside the + * adaptive shell (see [AdaptiveShell]), which the rail/drawer switches between + * without leaving the route. The ViewModel's boot logic picks [startRoute]. */ @Composable fun NativeLmApp(vm: NativeLmViewModel, startRoute: String) { val nav = rememberNavController() - NavHost(navController = nav, startDestination = startRoute) { + // Map the boot decision onto a real start destination. "models" (no model on + // disk) enters the shell directly on the Models tab; everything else flows. + val firstRoute = when (startRoute) { + ROUTE_ONBOARDING -> ROUTE_ONBOARDING + ROUTE_SPLASH -> ROUTE_SPLASH + else -> shellRoute(Destination.Models) + } + + NavHost(navController = nav, startDestination = firstRoute) { composable(ROUTE_SPLASH) { SplashScreen( vm = vm, onReady = { - nav.navigate(ROUTE_CHAT) { popUpTo(ROUTE_SPLASH) { inclusive = true } } + nav.navigate(shellRoute(Destination.Chat)) { + popUpTo(ROUTE_SPLASH) { inclusive = true } + } }, onFailed = { - nav.navigate(ROUTE_MODELS) { popUpTo(ROUTE_SPLASH) { inclusive = true } } + nav.navigate(shellRoute(Destination.Models)) { + popUpTo(ROUTE_SPLASH) { inclusive = true } + } }, ) } @@ -50,35 +61,21 @@ fun NativeLmApp(vm: NativeLmViewModel, startRoute: String) { OnboardingScreen( onFinish = { vm.completeOnboarding() - nav.navigate(ROUTE_MODELS) { popUpTo(ROUTE_ONBOARDING) { inclusive = true } } - }, - ) - } - composable(ROUTE_MODELS) { - ModelManagementScreen( - vm = vm, - canGoBack = nav.previousBackStackEntry != null, - onBack = { nav.popBackStack() }, - onContinue = { - nav.navigate(ROUTE_CHAT) { popUpTo(ROUTE_MODELS) { inclusive = true } } + nav.navigate(shellRoute(Destination.Models)) { + popUpTo(ROUTE_ONBOARDING) { inclusive = true } + } }, ) } - composable(ROUTE_CHAT) { - ChatScreen( + composable( + route = "$ROUTE_SHELL/{start}", + arguments = listOf(navArgument("start") { type = NavType.StringType; defaultValue = "chat" }), + ) { entry -> + val start = entry.arguments?.getString("start") + AdaptiveShell( vm = vm, - onOpenModels = { nav.navigate(ROUTE_MODELS) }, - onOpenSettings = { nav.navigate(ROUTE_SETTINGS) }, - onOpenDocuments = { nav.navigate(ROUTE_DOCUMENTS) }, + initial = if (start == "models") Destination.Models else Destination.Chat, onOpenPdf = { nav.navigate(ROUTE_PDF_VIEWER) }, - onOpenStudio = { nav.navigate(ROUTE_STUDIO) }, - ) - } - composable(ROUTE_STUDIO) { - StudioScreen( - vm = vm, - onBack = { nav.popBackStack() }, - onAskInChat = { nav.popBackStack(ROUTE_CHAT, inclusive = false) }, ) } composable(ROUTE_PDF_VIEWER) { @@ -90,18 +87,5 @@ fun NativeLmApp(vm: NativeLmViewModel, startRoute: String) { }, ) } - composable(ROUTE_DOCUMENTS) { - DocumentsScreen( - vm = vm, - onBack = { nav.popBackStack() }, - ) - } - composable(ROUTE_SETTINGS) { - SettingsScreen( - vm = vm, - onBack = { nav.popBackStack() }, - onOpenModels = { nav.navigate(ROUTE_MODELS) }, - ) - } } } diff --git a/sample-app/src/main/java/com/nativelm/app/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/chat/ChatScreen.kt index 8036038..16bb4c7 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/chat/ChatScreen.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -61,7 +64,9 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -115,6 +120,8 @@ fun ChatScreen( onOpenDocuments: () -> Unit, onOpenPdf: () -> Unit, onOpenStudio: () -> Unit, + showNavRail: Boolean = false, + expanded: Boolean = false, ) { val chat by vm.chat.collectAsState() val activeModel by vm.activeModelName.collectAsState() @@ -175,33 +182,19 @@ fun ChatScreen( } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ChatDrawer( - conversations = conversations, - projects = projects, - currentId = currentId, - onNewChat = { scope.launch { drawerState.close() }; vm.newChat() }, - onOpen = { id -> scope.launch { drawerState.close() }; vm.openConversation(id) }, - onRename = { renameTarget = it }, - onDelete = { vm.deleteConversation(it) }, - onOpenProject = { id -> scope.launch { drawerState.close() }; vm.openProject(id) }, - onNewProject = { newProjectName = "" }, - onRenameProject = { projectRenameTarget = it }, - onDeleteProject = { vm.deleteProject(it) }, - onOpenModels = { scope.launch { drawerState.close() }; onOpenModels() }, - onOpenSettings = { scope.launch { drawerState.close() }; onOpenSettings() }, - ) - }, - ) { + // The thread + composer. Shared by the Compact/Medium drawer layout and the + // Expanded two-pane layout; the hamburger only appears when there's no + // persistent conversations pane (i.e. not Expanded). + val chatContent: @Composable () -> Unit = { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( navigationIcon = { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Filled.Menu, contentDescription = "Menu") + if (!expanded) { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = "Menu") + } } }, title = { @@ -249,7 +242,12 @@ fun ChatScreen( ) { Box(Modifier.weight(1f)) { if (chat.messages.isEmpty()) { - Greeting(hasModel = activeModel != null, projectName = projectName, onOpenModels = onOpenModels) + Greeting( + hasModel = activeModel != null, + projectName = projectName, + onOpenModels = onOpenModels, + onSuggestion = { vm.setInput(it); vm.sendChatMessage() }, + ) } else { LazyColumn( state = listState, @@ -286,6 +284,57 @@ fun ChatScreen( } } + if (expanded) { + // Two-pane: persistent conversations list │ thread + composer. + Row(Modifier.fillMaxSize()) { + Surface( + modifier = Modifier.width(320.dp).fillMaxHeight(), + color = MaterialTheme.colorScheme.surface, + ) { + ConversationsList( + conversations = conversations, + projects = projects, + currentId = currentId, + onNewChat = { vm.newChat() }, + onOpen = { vm.openConversation(it) }, + onRename = { renameTarget = it }, + onDelete = { vm.deleteConversation(it) }, + onOpenProject = { vm.openProject(it) }, + onNewProject = { newProjectName = "" }, + onRenameProject = { projectRenameTarget = it }, + onDeleteProject = { vm.deleteProject(it) }, + onOpenModels = onOpenModels, + onOpenSettings = onOpenSettings, + ) + } + VerticalDivider(color = MaterialTheme.colorScheme.outline) + Box(Modifier.weight(1f)) { chatContent() } + } + } else { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ChatDrawer( + conversations = conversations, + projects = projects, + currentId = currentId, + onNewChat = { scope.launch { drawerState.close() }; vm.newChat() }, + onOpen = { id -> scope.launch { drawerState.close() }; vm.openConversation(id) }, + onRename = { renameTarget = it }, + onDelete = { vm.deleteConversation(it) }, + onOpenProject = { id -> scope.launch { drawerState.close() }; vm.openProject(id) }, + onNewProject = { newProjectName = "" }, + onRenameProject = { projectRenameTarget = it }, + onDeleteProject = { vm.deleteProject(it) }, + onOpenModels = { scope.launch { drawerState.close() }; onOpenModels() }, + onOpenSettings = { scope.launch { drawerState.close() }; onOpenSettings() }, + ) + }, + ) { + chatContent() + } + } + renameTarget?.let { target -> TextInputDialog( title = "Rename conversation", @@ -371,65 +420,104 @@ private fun ChatDrawer( onOpenModels: () -> Unit, onOpenSettings: () -> Unit, ) { - var sheet by remember { mutableStateOf(null) } ModalDrawerSheet { - Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NativeLmMark(size = 24.dp) - Spacer(Modifier.size(10.dp)) - Text("NativeLM", style = MaterialTheme.typography.titleLarge) - } - NavigationDrawerItem( - label = { Text("New chat") }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - selected = false, - onClick = onNewChat, - ) + ConversationsList( + conversations = conversations, + projects = projects, + currentId = currentId, + onNewChat = onNewChat, + onOpen = onOpen, + onRename = onRename, + onDelete = onDelete, + onOpenProject = onOpenProject, + onNewProject = onNewProject, + onRenameProject = onRenameProject, + onDeleteProject = onDeleteProject, + onOpenModels = onOpenModels, + onOpenSettings = onOpenSettings, + ) + } +} - LazyColumn(Modifier.weight(1f)) { - items(items = conversations, key = { "c${it.id}" }) { c -> - DrawerRow( - label = c.title, - selected = c.id == currentId, - onClick = { onOpen(c.id) }, - onLongClick = { sheet = DrawerSheet.Conv(c) }, - ) - } +/** + * The conversations + projects list — shared by the Compact/Medium modal drawer + * ([ChatDrawer]) and the Expanded two-pane left pane. Holds its own long-press + * action sheet so both presentations get rename/delete. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationsList( + conversations: List, + projects: List, + currentId: Long, + onNewChat: () -> Unit, + onOpen: (Long) -> Unit, + onRename: (ConversationSummary) -> Unit, + onDelete: (Long) -> Unit, + onOpenProject: (Long) -> Unit, + onNewProject: () -> Unit, + onRenameProject: (ProjectSummary) -> Unit, + onDeleteProject: (Long) -> Unit, + onOpenModels: () -> Unit, + onOpenSettings: () -> Unit, +) { + var sheet by remember { mutableStateOf(null) } + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NativeLmMark(size = 24.dp) + Spacer(Modifier.size(10.dp)) + Text("NativeLM", style = MaterialTheme.typography.titleLarge) + } + NavigationDrawerItem( + label = { Text("New chat") }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + selected = false, + onClick = onNewChat, + ) - item { - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - Text( - "Projects", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp), - ) - NavigationDrawerItem( - label = { Text("New project") }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - selected = false, - onClick = onNewProject, - ) - } - items(items = projects, key = { "p${it.id}" }) { p -> - DrawerRow( - label = p.name, - selected = false, - onClick = { onOpenProject(p.id) }, - onLongClick = { sheet = DrawerSheet.Proj(p) }, - icon = { Icon(Icons.Filled.Folder, contentDescription = null) }, - ) - } + LazyColumn(Modifier.weight(1f)) { + items(items = conversations, key = { "c${it.id}" }) { c -> + DrawerRow( + label = c.title, + selected = c.id == currentId, + onClick = { onOpen(c.id) }, + onLongClick = { sheet = DrawerSheet.Conv(c) }, + ) } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - NavigationDrawerItem(label = { Text("Models") }, selected = false, onClick = onOpenModels) - NavigationDrawerItem(label = { Text("Settings") }, selected = false, onClick = onOpenSettings) - Spacer(Modifier.height(8.dp)) + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text( + "Projects", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp), + ) + NavigationDrawerItem( + label = { Text("New project") }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + selected = false, + onClick = onNewProject, + ) + } + items(items = projects, key = { "p${it.id}" }) { p -> + DrawerRow( + label = p.name, + selected = false, + onClick = { onOpenProject(p.id) }, + onLongClick = { sheet = DrawerSheet.Proj(p) }, + icon = { Icon(Icons.Filled.Folder, contentDescription = null) }, + ) + } } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + NavigationDrawerItem(label = { Text("Models") }, selected = false, onClick = onOpenModels) + NavigationDrawerItem(label = { Text("Settings") }, selected = false, onClick = onOpenSettings) + Spacer(Modifier.height(8.dp)) } sheet?.let { target -> @@ -608,10 +696,33 @@ private fun BuildingUnderstanding() { } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun Greeting(hasModel: Boolean, projectName: String?, onOpenModels: () -> Unit) { +private fun Greeting( + hasModel: Boolean, + projectName: String?, + onOpenModels: () -> Unit, + onSuggestion: (String) -> Unit, +) { + // Tappable starters on an empty chat — project-aware, sent on tap. + val suggestions = when { + !hasModel -> emptyList() + projectName != null -> listOf( + "Summarize the key points", + "What are the main risks?", + "List the action items", + ) + else -> listOf( + "Explain a concept simply", + "Help me draft an email", + "Brainstorm ideas with me", + ) + } Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.widthIn(max = 480.dp), + ) { NativeLmMark(size = 48.dp) Spacer(Modifier.height(20.dp)) Text( @@ -637,6 +748,21 @@ private fun Greeting(hasModel: Boolean, projectName: String?, onOpenModels: () - Spacer(Modifier.height(8.dp)) TextButton(onClick = onOpenModels) { Text("Open Models") } } + if (suggestions.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + suggestions.forEach { s -> + SuggestionChip( + onClick = { onSuggestion(s) }, + label = { Text(s) }, + ) + } + } + } } } } diff --git a/sample-app/src/main/java/com/nativelm/app/ui/documents/DocumentsScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/documents/DocumentsScreen.kt index 7d4397a..766a61b 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/documents/DocumentsScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/documents/DocumentsScreen.kt @@ -53,10 +53,11 @@ import com.nativelm.app.llm.DocumentSummary import com.nativelm.app.llm.NativeLmViewModel import com.sagar.aicore.rag.IngestState import com.nativelm.app.ui.theme.JetBrainsMono +import com.nativelm.app.ui.theme.WideContent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DocumentsScreen(vm: NativeLmViewModel, onBack: () -> Unit) { +fun DocumentsScreen(vm: NativeLmViewModel, onBack: () -> Unit, showBack: Boolean = true) { val documents by vm.documents.collectAsState() val importState by vm.importState.collectAsState() val context = LocalContext.current @@ -79,8 +80,10 @@ fun DocumentsScreen(vm: NativeLmViewModel, onBack: () -> Unit) { TopAppBar( title = { Text("Sources") }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + if (showBack) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } } }, colors = TopAppBarDefaults.topAppBarColors( @@ -89,9 +92,9 @@ fun DocumentsScreen(vm: NativeLmViewModel, onBack: () -> Unit) { ) }, ) { padding -> + WideContent(padding) { Column( Modifier - .padding(padding) .fillMaxSize() .padding(horizontal = 16.dp), ) { @@ -133,6 +136,7 @@ fun DocumentsScreen(vm: NativeLmViewModel, onBack: () -> Unit) { } } } + } } } diff --git a/sample-app/src/main/java/com/nativelm/app/ui/models/ModelManagementScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/models/ModelManagementScreen.kt index 32ecf85..251298e 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/models/ModelManagementScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/models/ModelManagementScreen.kt @@ -4,10 +4,12 @@ */ package com.nativelm.app.ui.models +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -18,6 +20,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button @@ -57,6 +61,7 @@ import com.nativelm.app.llm.ModelStatus import com.nativelm.app.llm.ModelUi import com.nativelm.app.llm.NativeLmViewModel import com.nativelm.app.ui.theme.JetBrainsMono +import com.nativelm.app.ui.theme.WideContent @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,9 +76,18 @@ fun ModelManagementScreen( val activeId by vm.activeModelId.collectAsState() val llms = models.filter { it.descriptor.role == ModelRole.LLM_PRIMARY } + // Split the chat models by whether they need a Hugging Face account. The + // ungated (Apache-2.0 / MIT) models download with no token — the friction-free + // path for a fresh install — so they lead. The gated Gemma tier sits behind a + // collapsed "Advanced" section together with the token field. + val recommendedLlms = llms.filter { !it.descriptor.requiresAuth } + val advancedLlms = llms.filter { it.descriptor.requiresAuth } val embedders = models.filter { it.descriptor.role == ModelRole.EMBEDDING } val audio = models.filter { it.descriptor.role == ModelRole.SPEECH_TO_TEXT } + val recommendedId = vm.recommendedModelId + val context = LocalContext.current + var advancedExpanded by remember { mutableStateOf(hasToken) } var licensePrompt by remember { mutableStateOf(null) } // Gated models need their HF license accepted with the token's account; // intercept the download to remind the user (with a link) rather than letting @@ -118,24 +132,29 @@ fun ModelManagementScreen( }, containerColor = MaterialTheme.colorScheme.background, ) { padding -> + WideContent(padding) { LazyColumn( - modifier = Modifier - .padding(padding) - .fillMaxWidth(), + modifier = Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { TokenCard(hasToken = hasToken, onSave = vm::setToken, onClear = vm::clearToken) } - - item { SectionHeader("Language models") } - items(llms, key = { it.descriptor.id }) { model -> - ModelCard(model, vm, onDownloadRequest) + item { + SectionHeader("Recommended") + Text( + "Free, open models — no account needed. Downloads start right away.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + ) + } + items(recommendedLlms, key = { it.descriptor.id }) { model -> + ModelCard(model, vm, onDownloadRequest, recommended = model.descriptor.id == recommendedId) } item { SectionHeader("Document / RAG models") Text( - "for upcoming document chat", + "for document chat (Projects)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), @@ -159,6 +178,22 @@ fun ModelManagementScreen( ModelCard(model, vm, onDownloadRequest) } } + + if (advancedLlms.isNotEmpty()) { + item { + AdvancedHeader( + expanded = advancedExpanded, + onToggle = { advancedExpanded = !advancedExpanded }, + ) + } + if (advancedExpanded) { + item { TokenCard(hasToken = hasToken, onSave = vm::setToken, onClear = vm::clearToken) } + items(advancedLlms, key = { it.descriptor.id }) { model -> + ModelCard(model, vm, onDownloadRequest) + } + } + } + } } } @@ -202,6 +237,36 @@ private fun SectionHeader(text: String) { ) } +/** Collapsible header for the gated (Hugging Face account) tier. */ +@Composable +private fun AdvancedHeader(expanded: Boolean, onToggle: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 4.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = "ADVANCED — HUGGING FACE ACCOUNT", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = JetBrainsMono), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Gemma models. Free, but need a Hugging Face account + token and a one-time license accept.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TokenCard(hasToken: Boolean, onSave: (String) -> Unit, onClear: () -> Unit) { @@ -272,7 +337,12 @@ private fun TokenCard(hasToken: Boolean, onSave: (String) -> Unit, onClear: () - } @Composable -private fun ModelCard(model: ModelUi, vm: NativeLmViewModel, onDownload: (ModelUi) -> Unit) { +private fun ModelCard( + model: ModelUi, + vm: NativeLmViewModel, + onDownload: (ModelUi) -> Unit, + recommended: Boolean = false, +) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), modifier = Modifier.fillMaxWidth(), @@ -284,12 +354,19 @@ private fun ModelCard(model: ModelUi, vm: NativeLmViewModel, onDownload: (ModelU modifier = Modifier.fillMaxWidth(), ) { Column(Modifier.weight(1f)) { - Text( - model.displayName, - style = MaterialTheme.typography.titleMedium, - color = if (model.supported) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + model.displayName, + style = MaterialTheme.typography.titleMedium, + color = if (model.supported) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (recommended && model.supported) { + androidx.compose.foundation.layout.Box(Modifier.padding(start = 8.dp)) { + AssistBadge("Recommended") + } + } + } Spacer(Modifier.height(2.dp)) Text( metadataLine(model), diff --git a/sample-app/src/main/java/com/nativelm/app/ui/onboarding/OnboardingScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/onboarding/OnboardingScreen.kt index 7240907..2989386 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/onboarding/OnboardingScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/onboarding/OnboardingScreen.kt @@ -4,6 +4,8 @@ */ package com.nativelm.app.ui.onboarding +import android.content.Intent +import android.net.Uri import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -62,10 +65,13 @@ private val SLIDES = listOf( Slide( Icons.Outlined.CloudDownload, "You control the models", - "Bring your own models. Download them directly from Hugging Face with your access token and switch whenever you like.", + "Start with a free, open model — no account needed. Advanced Gemma models are a tap away with a free Hugging Face token. Switch whenever you like.", ), ) +private const val SOURCE_URL = "https://github.com/sagar-develop/litertlm-kmp" +private const val GEMMA_TERMS_URL = "https://ai.google.dev/gemma/terms" + @Composable fun OnboardingScreen(onFinish: () -> Unit) { val pagerState = rememberPagerState(pageCount = { SLIDES.size }) @@ -126,46 +132,82 @@ fun OnboardingScreen(onFinish: () -> Unit) { } } - Row( + Column( modifier = Modifier .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - TextButton(onClick = onFinish) { - Text(if (isLast) "" else "Skip") + // Terms / source gate: shown on the last slide before "Get started". + // NativeLM is AGPL-3.0 (source linked); downloaded models carry their + // own licenses (e.g. Google's Gemma Terms for the Gemma tier). + if (isLast) { + TermsFooter() + Spacer(Modifier.height(4.dp)) } Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - repeat(SLIDES.size) { i -> - val active = i == pagerState.currentPage - val color by animateColorAsState( - if (active) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline, - label = "dot", - ) - Box( - Modifier - .size(if (active) 9.dp else 7.dp) - .clip(CircleShape) - .background(color), - ) + TextButton(onClick = onFinish) { + Text(if (isLast) "" else "Skip") } - } - Button( - onClick = { - if (isLast) onFinish() - else scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } - }, - ) { - Text(if (isLast) "Get started" else "Next") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(SLIDES.size) { i -> + val active = i == pagerState.currentPage + val color by animateColorAsState( + if (active) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + label = "dot", + ) + Box( + Modifier + .size(if (active) 9.dp else 7.dp) + .clip(CircleShape) + .background(color), + ) + } + } + + Button( + onClick = { + if (isLast) onFinish() + else scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + }, + ) { + Text(if (isLast) "Get started" else "Next") + } } } } } + +/** Source + model-license disclosure shown on the final onboarding slide. */ +@Composable +private fun TermsFooter() { + val context = LocalContext.current + fun open(url: String) = runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Open source (AGPL-3.0). Downloaded models are subject to their own licenses.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { open(SOURCE_URL) }) { Text("Source code") } + TextButton(onClick = { open(GEMMA_TERMS_URL) }) { Text("Gemma Terms") } + } + } +} diff --git a/sample-app/src/main/java/com/nativelm/app/ui/settings/SettingsScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/settings/SettingsScreen.kt index c4e7fef..ef733a2 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/settings/SettingsScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/settings/SettingsScreen.kt @@ -4,6 +4,8 @@ */ package com.nativelm.app.ui.settings +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -48,6 +50,10 @@ import com.nativelm.app.llm.NativeLmViewModel import com.nativelm.app.ui.lock.canAuthenticate import com.nativelm.app.ui.sync.SyncControls import com.nativelm.app.ui.theme.JetBrainsMono +import com.nativelm.app.ui.theme.WideContent + +/** Hosted privacy policy (GitHub Pages). Mirrors PRIVACY.md / docs/privacy/. */ +private const val PRIVACY_URL = "https://sagar-develop.github.io/litertlm-kmp/privacy/" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -55,6 +61,7 @@ fun SettingsScreen( vm: NativeLmViewModel, onBack: () -> Unit, onOpenModels: () -> Unit, + showBack: Boolean = true, ) { val themeMode by vm.themeMode.collectAsState() val activeModel by vm.activeModelName.collectAsState() @@ -73,8 +80,10 @@ fun SettingsScreen( TopAppBar( title = { Text("Settings") }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + if (showBack) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } } }, colors = TopAppBarDefaults.topAppBarColors( @@ -83,9 +92,10 @@ fun SettingsScreen( ) }, ) { padding -> + WideContent(padding) { Column( Modifier - .padding(padding) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { @@ -203,6 +213,11 @@ fun SettingsScreen( Section("About") ValueRow(label = "Version", value = BuildConfig.VERSION_NAME, mono = true) ValueRow(label = "License", value = "AGPL-3.0", mono = true) + NavRow(label = "Privacy policy") { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRIVACY_URL))) + } + } Spacer(Modifier.height(8.dp)) Text( "No telemetry · No account · No upload", @@ -212,6 +227,7 @@ fun SettingsScreen( ) Spacer(Modifier.height(24.dp)) } + } } if (confirmClear) { diff --git a/sample-app/src/main/java/com/nativelm/app/ui/studio/StudioScreen.kt b/sample-app/src/main/java/com/nativelm/app/ui/studio/StudioScreen.kt index 9190a63..f189308 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/studio/StudioScreen.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/studio/StudioScreen.kt @@ -110,10 +110,11 @@ import com.sagar.aicore.studio.parseTimeline import com.sagar.aicore.studio.parseTopics import com.nativelm.app.ui.chat.MarkdownText import com.nativelm.app.ui.theme.JetBrainsMono +import com.nativelm.app.ui.theme.WideContent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StudioScreen(vm: NativeLmViewModel, onBack: () -> Unit, onAskInChat: () -> Unit) { +fun StudioScreen(vm: NativeLmViewModel, onBack: () -> Unit, onAskInChat: () -> Unit, showBack: Boolean = true) { val studio by vm.studio.collectAsState() val documents by vm.documents.collectAsState() val projectName by vm.currentProjectName.collectAsState() @@ -165,8 +166,10 @@ fun StudioScreen(vm: NativeLmViewModel, onBack: () -> Unit, onAskInChat: () -> U } }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + if (showBack) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } } }, colors = TopAppBarDefaults.topAppBarColors( @@ -176,10 +179,9 @@ fun StudioScreen(vm: NativeLmViewModel, onBack: () -> Unit, onAskInChat: () -> U }, ) { padding -> val canGenerate = documents.isNotEmpty() && !studio.generating + WideContent(padding) { LazyColumn( - modifier = Modifier - .padding(padding) - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 40.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -260,6 +262,7 @@ fun StudioScreen(vm: NativeLmViewModel, onBack: () -> Unit, onAskInChat: () -> U } } } + } } pendingType?.let { type -> diff --git a/sample-app/src/main/java/com/nativelm/app/ui/theme/Dimens.kt b/sample-app/src/main/java/com/nativelm/app/ui/theme/Dimens.kt new file mode 100644 index 0000000..a7e661b --- /dev/null +++ b/sample-app/src/main/java/com/nativelm/app/ui/theme/Dimens.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Sagar Gupta + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nativelm.app.ui.theme + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Formalized layout tokens for the adaptive UI (see docs/prototype/DESIGN.md §1). + * Colors and type are locked in [Color] / [Type]; this adds the spacing, radius, + * and elevation discipline the redesign asks for, without touching the brand. + * + * Plain `dp` constants (not a CompositionLocal) — the scale is global and never + * themed per-surface, so a simple object keeps call sites readable: `Spacing.lg`. + */ +object Spacing { + /** Strict 8pt grid (with a 4dp half-step for tight metadata rows). */ + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 20.dp + val xxl = 24.dp + val xxxl = 32.dp + val huge = 40.dp + val giant = 48.dp +} + +/** Corner radii: sm 10 · md 14 · lg 20 · xl 28 (pill = 50%). */ +object Radius { + val sm = 10.dp + val md = 14.dp + val lg = 20.dp + val xl = 28.dp +} + +/** + * Reading-column cap. On Medium/Expanded windows content panes center and stop + * widening here so text never runs edge-to-edge on a tablet (DESIGN.md §2). + */ +object Layout { + val contentMaxWidth = 760.dp + /** Hairline border weight for cards (soft warm elevation, not heavy shadow). */ + val hairline = 1.dp +} + +/** + * Centers content and caps it at [Layout.contentMaxWidth] so a reading column + * never runs edge-to-edge on a Medium/Expanded window. On Compact it's a no-op + * (the cap is wider than the screen). Apply the scaffold's inner [padding] here. + */ +@Composable +fun WideContent( + padding: PaddingValues, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.TopCenter, + ) { + Box(Modifier.widthIn(max = Layout.contentMaxWidth).fillMaxSize()) { + content() + } + } +} + +/** Material3 shape scale mapped onto [Radius] so components inherit the brand radii. */ +val NativeLmShapes = Shapes( + extraSmall = RoundedCornerShape(Radius.sm), + small = RoundedCornerShape(Radius.sm), + medium = RoundedCornerShape(Radius.md), + large = RoundedCornerShape(Radius.lg), + extraLarge = RoundedCornerShape(Radius.xl), +) diff --git a/sample-app/src/main/java/com/nativelm/app/ui/theme/Theme.kt b/sample-app/src/main/java/com/nativelm/app/ui/theme/Theme.kt index 4913a0d..c4dfb6e 100644 --- a/sample-app/src/main/java/com/nativelm/app/ui/theme/Theme.kt +++ b/sample-app/src/main/java/com/nativelm/app/ui/theme/Theme.kt @@ -58,6 +58,7 @@ fun NativeLmTheme( MaterialTheme( colorScheme = if (darkTheme) DarkColors else LightColors, typography = NativeLmTypography, + shapes = NativeLmShapes, content = content, ) } diff --git a/sample-app/src/main/res/xml/network_security_config.xml b/sample-app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..485fe7c --- /dev/null +++ b/sample-app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + +