diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfecde..012f4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to Sonario for Android are documented here. +## 1.5.0 + +- Replaces the previous Qwen2.5 1.5B, Llama 3.2 3B, and Phi-3.5 Mini downloads + with three newer mobile-oriented choices: + - Qwen3 4B Instruct 2507 for the strongest overall local summaries and Q&A. + - Gemma 3n E4B Instruct for nuanced long-form summaries and writing. + - LFM2 2.6B for the smallest download and fastest local responses. +- Shows a plain-language strength/tradeoff description and formatted download size + before the user downloads each model. +- Updates the Android llama.cpp wrapper to Llamatik 1.8.1 for current Qwen3, + Gemma 3n, and LFM2 GGUF support. +- Adds a free-storage check and fixes resumable downloads when a host ignores an + HTTP Range request. +- Removes obsolete local-model and partial-download files left by older versions. +- Keeps all model files in private app storage, explicitly disables uninstall data + retention, and excludes the `models/` directory from cloud backup and device + transfer so multi-gigabyte models cannot return after reinstall. + ## 1.4.0 - Replaces the retired Llama 4 Scout cloud model with the fixed Groq model diff --git a/README.md b/README.md index 82af606..cb1eb36 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ or a bulleted outline. **Status:** working. YouTube transcript summarization, web-article and pasted-text summarization, Groq cloud, on-device inference, saved sessions, resumable -checkpoints, and source Q&A are functional as of version 1.4.0. +checkpoints, and source Q&A are functional as of version 1.5.0. Sonario has two engines, and you pick which to use per summary: @@ -14,9 +14,9 @@ Sonario has two engines, and you pick which to use per summary: with Qwen 3.6 27B. Sonario splits large sources into rate-safe requests, waits for Groq's minute windows, and checkpoints each completed call. You bring your own API key. Your text goes to Groq's servers. -- **On-device** - runs a model locally via llama.cpp. Private (nothing leaves the - phone except fetching the link), but slow: CPU-only, so a summary takes minutes - and the phone warms up. A private fallback rather than the daily driver. +- **On-device** - runs a downloaded GGUF locally through llama.cpp. Source text + remains private after Sonario fetches it, but phone inference is slower than + Groq and can warm the device during long summaries. ## Highlights @@ -26,6 +26,8 @@ Sonario has two engines, and you pick which to use per summary: IDs from being restored by an old setting or saved session. - Queues cloud requests against conservative TPM/RPM limits and Groq's live reset headers instead of repeatedly failing with minute-based 429 errors. +- Offers three newer local choices with clear quality/speed tradeoffs: Qwen3 4B, + Gemma 3n E4B, and LFM2 2.6B. - Keeps long cloud summaries alive with a foreground service and retries temporary DNS, timeout, and network-handoff failures. - Saves up to 12 recent sessions locally, restores the latest session on launch, @@ -53,8 +55,8 @@ Sonario has two engines, and you pick which to use per summary: ## Quick start (Groq cloud, the fast path) 1. Install the APK (see below) and open Sonario. -2. On the first screen, tap **Use Groq cloud instead** (skips the local-model - download). +2. On the first screen, tap **Use Groq cloud instead** to skip the local-model + download. 3. Get a Groq API key at console.groq.com and create a key. 4. In Sonario's Settings, paste the key and tap **Save key**. 5. Back on the main screen, make sure the toggle is on **Groq cloud**, paste a @@ -68,11 +70,13 @@ keys in the same organization share the same quota and do not multiply it. ## Quick start (on-device, fully private) -1. Open Sonario. On the first screen, tap **Get** on a model (Qwen2.5 1.5B is the - fastest). It downloads in-app (~1 GB, use Wi-Fi). -2. When it finishes, make sure the toggle is on **On-device**, paste a source, - and tap **Summarize**. Expect it to take a few minutes; the CPU meter at the - bottom-left will sit near 100%. +1. Open Sonario and choose a local model: + - **Qwen3 4B Instruct 2507** for the strongest overall local summaries and Q&A. + - **Gemma 3n E4B Instruct** for nuanced long-form summaries and writing. + - **LFM2 2.6B** for the smallest download and fastest local responses. +2. Tap **Get** and keep the phone on Wi-Fi. Downloads range from about 1.6 GB to + 4.3 GB. +3. When it finishes, select **On-device**, paste a source, and tap **Summarize**. ## Supported links @@ -95,7 +99,7 @@ The two engines differ, and the app shows which is active: one-time model download and fetching whatever link you paste. - Groq cloud: the text to summarize is sent to Groq. Don't use this for anything you wouldn't send to a third-party API. Your Groq key is stored only on your - device (local app storage) and is sent solely to Groq. + device and is sent solely to Groq. ## How YouTube transcripts are fetched @@ -148,26 +152,33 @@ build 2** diagnostics so you can confirm the new APK is actually installed. entered, and the app no longer treats a failed Ask call as a successful generic answer. -## On-device models (downloaded on first run, not bundled) +## On-device models (downloaded, never bundled) -| Model | Size | Notes | -| --- | --- | --- | -| Qwen2.5 1.5B Instruct | ~1.1 GB | Fastest on-device; the default | -| Llama 3.2 3B Instruct | ~2.0 GB | Better output, slower | -| Phi-3.5 Mini Instruct | ~2.3 GB | Larger context, slowest | +| Model | Download | Best for | Tradeoff | +| --- | ---: | --- | --- | +| Qwen3 4B Instruct 2507 Q4_K_M | ~2.5 GB | Best overall local summaries, comprehension, and follow-up answers | Slower than LFM2 | +| Gemma 3n E4B Instruct Q4_K_M | ~4.24 GB | Nuanced long-form summaries and polished writing | Largest and slowest option | +| LFM2 2.6B Q4_K_M | ~1.56 GB | Fast summaries, extraction, and lower storage use | Weaker on difficult or subtle material | -Q4_K_M GGUF from public Hugging Face repos, downloaded at first launch to -`Android/data/ai.sonario.app/files/models/`. Never bundled in the APK. +Sonario downloads the GGUF files from public Hugging Face repositories into +`filesDir/models`, Android's private app-specific storage. The app checks free +space before starting, supports resumable downloads, and removes obsolete models +from versions 1.4 and earlier. -On-device inference is CPU-only: the llama.cpp build here has no Adreno GPU -backend, so on a Snapdragon phone every token is generated on CPU. It works and -is genuinely private, but Groq is far more practical for everyday use. +Android deletes app-specific files when Sonario is uninstalled. Sonario also sets +`hasFragileUserData=false` and excludes `models/` from cloud backup and +phone-to-phone transfer, so multi-gigabyte model files are not retained or +restored after reinstall. + +The app uses Llamatik 1.8.1, an Android wrapper around llama.cpp. It requests +available acceleration but may fall back to CPU depending on the device and the +runtime build. Local models remain useful for privacy and offline work, while the +27B Groq model remains substantially stronger and faster for everyday use. ## Crash diagnostics -If the app ever crashes, it writes the stack trace to -`Android/data/ai.sonario.app/files/last_crash.txt` and shows it on the next -launch (with a Copy button) instead of silently closing. +If the app ever crashes, it writes the stack trace to its private app files and +shows it on the next launch with a Copy button instead of silently closing. ## Architecture @@ -178,18 +189,17 @@ and the summarize pipeline talks only to that: - `llm/LlmEngine.kt` - on-device via Llamatik/llama.cpp. - `llm/GroqEngine.kt` - Qwen 3.6 through Groq's OpenAI-compatible streaming API. - `llm/RateLimiter.kt` - local pacing plus synchronization with Groq reset headers. -- `llm/ModelDownloader.kt` - resumable GGUF download. +- `llm/ModelDownloader.kt` - resumable GGUF downloads with free-space checks. - `data/Settings.kt` - engine choice and Groq key (local preferences). - `source/SourceFetcher.kt` - YouTube (InnerTube) and web-article fetching. - `summarize/SummarizeEngine.kt` - map-reduce summarizer with bounded cloud calls - sized for Groq's TPM limits and small CPU-bounded on-device chunks. + and small, phone-safe local chunks. - `summarize/Prompts.kt` - prompts carried over from Sonario desktop. -- `ui/` - Compose screens, theme, settings, the CPU/RAM meter, crash screen. +- `ui/` - Compose screens, theme, settings, system meter, and crash screen. - `CrashReporter.kt` - global uncaught-exception logger. -To add another backend (e.g. a self-hosted llama.cpp/Ollama server on a PC), -implement `InferenceEngine` and hand it to `SummarizeEngine`. `GroqEngine` is the -template for an HTTP-based engine. +To add another backend, implement `InferenceEngine` and hand it to +`SummarizeEngine`. `GroqEngine` is the template for an HTTP-based engine. ## Build diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 92f29bd..eb71368 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,12 +10,12 @@ android { defaultConfig { applicationId = "ai.sonario.app" - minSdk = 28 // Android 9. 8 Elite phones are far above this. + minSdk = 28 targetSdk = 36 - versionCode = 10 - versionName = "1.4.0" + versionCode = 11 + versionName = "1.5.0" vectorDrawables { useSupportLibrary = true } - ndk { abiFilters += "arm64-v8a" } // modern phones; keeps APK lean + ndk { abiFilters += "arm64-v8a" } } buildTypes { @@ -54,19 +54,17 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") implementation("androidx.navigation:navigation-compose:2.8.1") - // Networking for source fetching (YouTube captions, web articles) + // Networking for source fetching and resumable model downloads. implementation("com.squareup.okhttp3:okhttp:4.12.0") - // HTML parsing for web-article extraction (the Jsoup analogue of BeautifulSoup) implementation("org.jsoup:jsoup:1.18.1") - // PDF text extraction for the local-file picker. + // Local document extraction. implementation("com.tom-roush:pdfbox-android:2.0.27.0") - // Markdown rendering for the summary view (core + Material 3 module). + // Markdown rendering for summary output. implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") implementation("com.mikepenz:multiplatform-markdown-renderer-m3:0.27.0") - // On-device LLM via llama.cpp, through the Llamatik Maven library. - // No NDK, no native build: it ships prebuilt arm64 binaries. - implementation("com.llamatik:library-android:1.7.0") + // Latest Llamatik/llama.cpp Android runtime for Qwen3, Gemma 3n and LFM2 GGUFs. + implementation("com.llamatik:library-android:1.8.1") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df64e30..33339c8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,19 +2,19 @@ - - - - @@ -49,7 +48,6 @@ android:resource="@xml/file_paths" /> - = BUNDLED_MODELS.map { m -> - m.copy(present = File(modelsDir(), m.fileName).exists()) + fun availableModels(): List = BUNDLED_MODELS.map { model -> + model.copy(present = File(modelsDir(), model.fileName).exists()) } fun isModelPresent(model: ModelInfo): Boolean = File(modelsDir(), model.fileName).exists() /** - * Load a GGUF into llama.cpp. Idempotent. Heavy - call off the main thread - * (the summarize pipeline already does). + * Remove the three superseded model downloads from Sonario 1.4 and earlier. + * They are no longer offered, and silently leaving several gigabytes of + * unreachable files behind would waste storage after an app update. + */ + private fun cleanupLegacyModelsOnce(dir: File) { + if (legacyCleanupDone) return + synchronized(this) { + if (legacyCleanupDone) return + LEGACY_MODEL_FILES.forEach { name -> + File(dir, name).delete() + File(dir, "$name.part").delete() + } + legacyCleanupDone = true + } + } + + /** + * Load a GGUF into llama.cpp. Idempotent and intentionally performed off the + * main thread. Memory mapping keeps the resident-RAM cost below copying the + * complete multi-gigabyte file into memory. */ suspend fun ensureLoaded(model: ModelInfo) = withContext(Dispatchers.Default) { if (loadedModel == model.fileName) return@withContext @@ -52,28 +74,29 @@ class LlmEngine private constructor(private val appContext: Context) : Inference require(path.exists()) { "Model file missing: ${model.fileName}" } if (loadedModel != null) { - // switching models: free the old context first runCatching { LlamaBridge.shutdown() } loadedModel = null } - // Load-time params must be set before initGenerateModel. LlamaBridge.updateGenerateParams( - temperature = 0.5f, // summaries want low randomness + temperature = 0.35f, maxTokens = 1024, - topP = 0.95f, + topP = 0.9f, topK = 40, repeatPenalty = 1.1f, contextLength = model.contextTokens, numThreads = pickThreads(), - useMmap = true, // memory-map weights; lighter on RAM - flashAttention = false, + useMmap = true, + flashAttention = true, batchSize = 256, - gpuLayers = -1, // best-effort GPU offload; falls back to CPU if unsupported + gpuLayers = -1, ) val ok = LlamaBridge.initGenerateModel(path.absolutePath) - require(ok) { "Llamatik failed to load ${model.fileName}" } + require(ok) { + "Sonario could not load ${model.label}. The download may be incomplete " + + "or this phone may not have enough free memory." + } loadedModel = model.fileName } @@ -82,30 +105,25 @@ class LlmEngine private constructor(private val appContext: Context) : Inference loadedModel = null } - /** - * Stream a completion. Mirrors providers.Provider.chat(system, user): the - * system prompt steers, the user prompt carries the source. Emits token by - * token so the UI shows the summary forming live. - * - * Uses Llamatik's generateWithContextStream with an empty context block; - * Sonario folds everything into system + user, matching the desktop app. - */ + /** Stream one completion while keeping generation work off the UI thread. */ override fun stream(system: String, user: String, maxTokens: Int): Flow = callbackFlow { - // maxTokens can change per call without a reload. runCatching { LlamaBridge.updateGenerateParams( - temperature = 0.5f, maxTokens = maxTokens, topP = 0.95f, - topK = 40, repeatPenalty = 1.1f, - contextLength = currentContext(), numThreads = pickThreads(), - useMmap = true, flashAttention = false, batchSize = 256, + temperature = 0.35f, + maxTokens = maxTokens, + topP = 0.9f, + topK = 40, + repeatPenalty = 1.1f, + contextLength = currentContext(), + numThreads = pickThreads(), + useMmap = true, + flashAttention = true, + batchSize = 256, gpuLayers = -1, ) } - // Run generation in its own job. Llamatik's streaming call blocks the - // calling thread until generation completes (it drives the callbacks - // inline), so launching it here lets awaitClose register first and - // keeps this dispatcher free to deliver cancellation. + val job = launch(Dispatchers.Default) { try { LlamaBridge.generateWithContextStream( @@ -114,10 +132,10 @@ class LlmEngine private constructor(private val appContext: Context) : Inference user = user, onDelta = { token -> trySend(token) }, onDone = { close() }, - onError = { msg -> close(RuntimeException(msg)) }, + onError = { message -> close(RuntimeException(message)) }, ) - } catch (e: Throwable) { - close(e) + } catch (error: Throwable) { + close(error) } } awaitClose { @@ -131,13 +149,18 @@ class LlmEngine private constructor(private val appContext: Context) : Inference private fun pickThreads(): Int { val cores = Runtime.getRuntime().availableProcessors() - // Use the big cores. Modern flagships have 8 cores; leaving one free - // keeps the UI responsive while giving inference most of the CPU. return (cores - 1).coerceIn(4, 8) } companion object { + private val LEGACY_MODEL_FILES = listOf( + "qwen2.5-1.5b-instruct-q4_k_m.gguf", + "llama-3.2-3b-instruct-q4_k_m.gguf", + "phi-3.5-mini-instruct-q4_k_m.gguf", + ) + @Volatile private var INSTANCE: LlmEngine? = null + fun get(context: Context): LlmEngine = INSTANCE ?: synchronized(this) { INSTANCE ?: LlmEngine(context.applicationContext).also { INSTANCE = it } @@ -156,37 +179,35 @@ data class ModelInfo( ) /** - * Models that suit an 8GB-class phone (Snapdragon 8 Elite, ~12-16GB RAM). All - * are small enough to load fully into RAM and run at usable speed via llama.cpp - * with Q4_K_M quantization. The download URLs point at public GGUF repos on - * Hugging Face. The first-run picker downloads exactly one. + * Mobile-oriented local models. Q4_K_M is used as the quality/size compromise; + * context is deliberately capped at 4K to avoid an oversized KV cache on phones. */ val BUNDLED_MODELS = listOf( ModelInfo( - label = "Qwen2.5 1.5B Instruct", - fileName = "qwen2.5-1.5b-instruct-q4_k_m.gguf", - sizeMb = 1100, + label = "Qwen3 4B Instruct 2507", + fileName = "qwen3-4b-instruct-2507-q4_k_m.gguf", + sizeMb = 2500, contextTokens = 4096, - note = "Fast, good summaries. The safe default for any 8GB+ phone.", - downloadUrl = "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/" + - "resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf?download=true", + note = "Best overall. Strongest summaries, comprehension, and follow-up answers; larger and slower than LFM2.", + downloadUrl = "https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/" + + "resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf?download=true", ), ModelInfo( - label = "Llama 3.2 3B Instruct", - fileName = "llama-3.2-3b-instruct-q4_k_m.gguf", - sizeMb = 2000, + label = "Gemma 3n E4B Instruct", + fileName = "gemma-3n-e4b-it-q4_k_m.gguf", + sizeMb = 4240, contextTokens = 4096, - note = "Stronger synthesis, comfortable on the S26 Ultra. Slower.", - downloadUrl = "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/" + - "resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf?download=true", + note = "Best for nuanced long-form summaries and writing. Mobile-designed, but the largest download and slowest option.", + downloadUrl = "https://huggingface.co/second-state/gemma-3n-E4B-it-GGUF/" + + "resolve/main/gemma-3n-E4B-it-Q4_K_M.gguf?download=true", ), ModelInfo( - label = "Phi-3.5 Mini Instruct", - fileName = "phi-3.5-mini-instruct-q4_k_m.gguf", - sizeMb = 2300, + label = "LFM2 2.6B", + fileName = "lfm2-2.6b-q4_k_m.gguf", + sizeMb = 1560, contextTokens = 4096, - note = "Roomy context comfort for long transcripts.", - downloadUrl = "https://huggingface.co/bartowski/Phi-3.5-mini-instruct-GGUF/" + - "resolve/main/Phi-3.5-mini-instruct-Q4_K_M.gguf?download=true", + note = "Fastest and smallest. Good for quick summaries and extraction; less capable with difficult or subtle material.", + downloadUrl = "https://huggingface.co/LiquidAI/LFM2-2.6B-GGUF/" + + "resolve/main/LFM2-2.6B-Q4_K_M.gguf?download=true", ), ) diff --git a/app/src/main/java/ai/sonario/app/llm/ModelDownloader.kt b/app/src/main/java/ai/sonario/app/llm/ModelDownloader.kt index b33435f..5165770 100644 --- a/app/src/main/java/ai/sonario/app/llm/ModelDownloader.kt +++ b/app/src/main/java/ai/sonario/app/llm/ModelDownloader.kt @@ -13,16 +13,17 @@ import java.util.concurrent.TimeUnit import kotlin.coroutines.coroutineContext /** - * Downloads a GGUF model into the app's models dir, with progress and resume. - * This is what makes step 3 ("get a model onto the phone") happen in-app instead - * of by adb. Resume uses an HTTP Range request against a .part file, so a dropped - * connection on a 1-2GB download doesn't start over. + * Downloads a GGUF model into Sonario's private model directory with progress and + * HTTP Range resume. Partial downloads remain as .part files until resumed or + * Android removes the app's private data during uninstall. */ class ModelDownloader(private val modelsDir: File) { private val http = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) .build() sealed interface State { @@ -33,74 +34,117 @@ class ModelDownloader(private val modelsDir: File) { data class Failed(val message: String) : State } - /** - * Stream download states. The final emission is Done or Failed. Cancelling - * the collecting coroutine stops the download and leaves the .part file in - * place for a later resume. - */ fun download(model: ModelInfo): Flow = flow { + modelsDir.mkdirs() val target = File(modelsDir, model.fileName) - if (target.exists() && target.length() > 0) { - emit(State.Done(target)); return@flow + if (target.exists() && target.length() > 0L) { + emit(State.Done(target)) + return@flow } + val part = File(modelsDir, model.fileName + ".part") - val have = if (part.exists()) part.length() else 0L + var offset = if (part.exists()) part.length() else 0L + val estimatedBytes = model.sizeMb.toLong() * 1_000_000L + val remainingEstimate = (estimatedBytes - offset).coerceAtLeast(0L) + val requiredWithHeadroom = remainingEstimate + STORAGE_HEADROOM_BYTES + + if (modelsDir.usableSpace < requiredWithHeadroom) { + val neededMb = requiredWithHeadroom / 1_000_000L + val freeMb = modelsDir.usableSpace / 1_000_000L + emit( + State.Failed( + "Not enough free storage for ${model.label}. " + + "About $neededMb MB is needed, but only $freeMb MB is available." + ) + ) + return@flow + } - val reqBuilder = Request.Builder().url(model.downloadUrl) - .header("User-Agent", "Sonario/1.0") - if (have > 0) reqBuilder.header("Range", "bytes=$have-") + val requestBuilder = Request.Builder() + .url(model.downloadUrl) + .header("User-Agent", "Sonario/1.5") + if (offset > 0L) requestBuilder.header("Range", "bytes=$offset-") try { - http.newCall(reqBuilder.build()).execute().use { resp -> - if (!resp.isSuccessful && resp.code != 206) { - emit(State.Failed("Server returned ${resp.code}. " + - "Check the connection and try again.")) + http.newCall(requestBuilder.build()).execute().use { response -> + if (!response.isSuccessful && response.code != 206) { + emit( + State.Failed( + "Model server returned ${response.code}. Check the connection and try again." + ) + ) + return@flow + } + + val body = response.body ?: run { + emit(State.Failed("The model server returned an empty response.")) return@flow } - val body = resp.body ?: run { - emit(State.Failed("Empty response from server.")); return@flow + + // Some hosts ignore Range and return 200 with the complete file. + // Restart rather than appending a second full copy to the partial. + if (offset > 0L && response.code == 200) { + offset = 0L } - // total = already-downloaded + remaining content length val remaining = body.contentLength() - val total = if (remaining > 0) have + remaining - else model.sizeMb.toLong() * 1_000_000L // estimate - - val raf = RandomAccessFile(part, "rw") - raf.seek(have) - var written = have - - body.byteStream().use { input -> - val buf = ByteArray(64 * 1024) - var lastEmit = 0L - while (true) { - coroutineContext.ensureActive() // honour cancellation - val n = input.read(buf) - if (n < 0) break - raf.write(buf, 0, n) - written += n - // throttle UI updates to ~every 512KB - if (written - lastEmit > 512 * 1024) { - emit(State.Progress(written, total)) - lastEmit = written + val total = when { + response.code == 206 && remaining > 0L -> offset + remaining + remaining > 0L -> remaining + else -> estimatedBytes + } + + RandomAccessFile(part, "rw").use { output -> + if (offset == 0L) output.setLength(0L) + output.seek(offset) + var written = offset + var lastEmit = offset + + body.byteStream().use { input -> + val buffer = ByteArray(128 * 1024) + while (true) { + coroutineContext.ensureActive() + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + written += count + + if (written - lastEmit >= PROGRESS_STEP_BYTES) { + emit(State.Progress(written, total)) + lastEmit = written + } } } } - raf.close() + + val minimumPlausibleSize = estimatedBytes * 85L / 100L + if (part.length() < minimumPlausibleSize) { + emit( + State.Failed( + "The model download ended early. Keep the partial file and tap Retry to resume." + ) + ) + return@flow + } if (part.renameTo(target)) { emit(State.Progress(target.length(), target.length())) emit(State.Done(target)) } else { - emit(State.Failed("Could not finalize the downloaded file.")) + emit(State.Failed("Sonario could not finalize the downloaded model file.")) } } - } catch (e: Exception) { - emit(State.Failed(e.message ?: "Download failed.")) + } catch (error: Exception) { + emit(State.Failed(error.message ?: "Model download failed.")) } }.flowOn(Dispatchers.IO) fun deletePartial(model: ModelInfo) { File(modelsDir, model.fileName + ".part").delete() } + + companion object { + private const val STORAGE_HEADROOM_BYTES = 256L * 1_000_000L + private const val PROGRESS_STEP_BYTES = 512L * 1024L + } } diff --git a/app/src/main/java/ai/sonario/app/ui/ModelsScreen.kt b/app/src/main/java/ai/sonario/app/ui/ModelsScreen.kt index ac234b7..a1787b2 100644 --- a/app/src/main/java/ai/sonario/app/ui/ModelsScreen.kt +++ b/app/src/main/java/ai/sonario/app/ui/ModelsScreen.kt @@ -17,16 +17,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp /** - * Models screen, post-setup. Lets the user pick the active model, download - * additional models in-app (same downloader as first-run setup), and see which - * are present. One model loads at a time. + * Lets the user choose among Sonario's mobile-oriented local models, download + * additional models, and see which model is currently active. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelsScreen(vm: SummaryViewModel, onBack: () -> Unit) { val ui by vm.ui.collectAsState() val scroll = rememberScrollState() - val dl = ui.download + val download = ui.download LaunchedEffect(Unit) { vm.refreshModels() } Scaffold( @@ -36,68 +35,97 @@ fun ModelsScreen(vm: SummaryViewModel, onBack: () -> Unit) { title = { Text("Models", color = SonarioColors.Ink) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", - tint = SonarioColors.InkSoft) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + "Back", + tint = SonarioColors.InkSoft, + ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = SonarioColors.Deep), + containerColor = SonarioColors.Deep, + ), ) - } - ) { pad -> + }, + ) { padding -> Column( - Modifier.padding(pad).verticalScroll(scroll).padding(16.dp) + Modifier + .padding(padding) + .verticalScroll(scroll) + .padding(16.dp), ) { - Text("Pick the on-device model. One model stays loaded at a time. " + - "Download more here any time.", + Text( + "Pick the on-device model that matches what you care about most. " + + "Only one model is loaded into memory at a time.", color = SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) + style = MaterialTheme.typography.bodyMedium, + ) Spacer(Modifier.height(14.dp)) - ui.models.forEach { m -> - val selected = m.fileName == ui.model.fileName && m.present - val downloadingThis = dl.active && dl.model?.fileName == m.fileName + ui.models.forEach { model -> + val selected = model.fileName == ui.model.fileName && model.present + val downloadingThis = + download.active && download.model?.fileName == model.fileName + Surface( color = if (selected) SonarioColors.Panel2 else SonarioColors.Panel, shape = RoundedCornerShape(14.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - onClick = { if (m.present) vm.setModel(m) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + onClick = { if (model.present) vm.setModel(model) }, ) { Column(Modifier.padding(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - if (m.present) { + if (model.present) { Icon( if (selected) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, contentDescription = null, tint = if (selected) SonarioColors.Green - else SonarioColors.Muted, + else SonarioColors.Muted, ) Spacer(Modifier.width(12.dp)) } + Column(Modifier.weight(1f)) { - Text(m.label, color = SonarioColors.Ink, - fontWeight = FontWeight.SemiBold) - Text(m.note, color = SonarioColors.Muted, - style = MaterialTheme.typography.bodyMedium) Text( - if (m.present) "On device, ${m.sizeMb} MB" - else "Not downloaded, ~${m.sizeMb} MB", - color = if (m.present) SonarioColors.Green - else SonarioColors.Muted, + model.label, + color = SonarioColors.Ink, + fontWeight = FontWeight.SemiBold, + ) + Text( + model.note, + color = SonarioColors.Muted, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + if (model.present) { + "Downloaded • ${formatModelSize(model.sizeMb)}" + } else { + "Not downloaded • about ${formatModelSize(model.sizeMb)}" + }, + color = if (model.present) SonarioColors.Green + else SonarioColors.Muted, style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 4.dp)) + modifier = Modifier.padding(top = 4.dp), + ) } - if (!m.present && !downloadingThis) { + + if (!model.present && !downloadingThis) { Button( - onClick = { vm.downloadModel(m) }, - enabled = !dl.active, + onClick = { vm.downloadModel(model) }, + enabled = !download.active, colors = ButtonDefaults.buttonColors( containerColor = SonarioColors.Green, - contentColor = SonarioColors.Abyss), + contentColor = SonarioColors.Abyss, + ), ) { - Icon(Icons.Filled.Download, contentDescription = null, - modifier = Modifier.size(18.dp)) + Icon( + Icons.Filled.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) Spacer(Modifier.width(6.dp)) Text("Get") } @@ -107,18 +135,23 @@ fun ModelsScreen(vm: SummaryViewModel, onBack: () -> Unit) { if (downloadingThis) { Spacer(Modifier.height(12.dp)) LinearProgressIndicator( - progress = { dl.fraction.coerceIn(0f, 1f) }, + progress = { download.fraction.coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth(), color = SonarioColors.Green, trackColor = SonarioColors.Panel, ) Row( - Modifier.fillMaxWidth().padding(top = 8.dp), + Modifier + .fillMaxWidth() + .padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text("${dl.bytes / 1_000_000} / ${dl.total / 1_000_000} MB", + Text( + "${download.bytes / 1_000_000} / " + + "${download.total / 1_000_000} MB", color = SonarioColors.InkSoft, - style = MaterialTheme.typography.labelLarge) + style = MaterialTheme.typography.labelLarge, + ) Spacer(Modifier.weight(1f)) TextButton(onClick = { vm.cancelDownload() }) { Text("Cancel", color = SonarioColors.Muted) @@ -126,14 +159,32 @@ fun ModelsScreen(vm: SummaryViewModel, onBack: () -> Unit) { } } - if (dl.model?.fileName == m.fileName && dl.error != null) { + if (download.model?.fileName == model.fileName && + download.error != null + ) { Spacer(Modifier.height(8.dp)) - Text(dl.error!!, color = SonarioColors.InkSoft, - style = MaterialTheme.typography.bodyMedium) + Text( + download.error!!, + color = SonarioColors.InkSoft, + style = MaterialTheme.typography.bodyMedium, + ) } } } } + + Spacer(Modifier.height(6.dp)) + Text( + "Downloaded and partial model files stay inside Sonario's private " + + "app storage. Android deletes them when Sonario is uninstalled.", + color = SonarioColors.Muted, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(16.dp)) } } } + +private fun formatModelSize(sizeMb: Int): String = + if (sizeMb >= 1000) String.format("%.1f GB", sizeMb / 1000.0) + else "$sizeMb MB" diff --git a/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt b/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt index ace8d89..9f9d190 100644 --- a/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt +++ b/app/src/main/java/ai/sonario/app/ui/SettingsScreen.kt @@ -64,8 +64,8 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { Spacer(Modifier.height(10.dp)) EngineOption( title = "On-device", - subtitle = "Runs the model on your phone. Private, but slow " + - "(CPU only). Needs a downloaded model.", + subtitle = "Runs a downloaded model privately on your phone. " + + "No source text is sent to an AI provider.", selected = ui.engineChoice == EngineChoice.ON_DEVICE, onClick = { vm.setEngine(EngineChoice.ON_DEVICE) }, ) @@ -197,7 +197,7 @@ fun SettingsScreen(vm: SummaryViewModel, onBack: () -> Unit) { HorizontalDivider(color = SonarioColors.RuleSoft) Spacer(Modifier.height(12.dp)) Text( - "Sonario 1.4.0 • Qwen 3.6 cloud and rate-aware queueing", + "Sonario 1.5.0 • mobile-focused local model choices", color = SonarioColors.Muted, style = MaterialTheme.typography.bodySmall, ) @@ -250,7 +250,6 @@ private fun EngineOption( } } -/** Compact token count: 480000 -> "480K", 1200000 -> "1.2M". */ private fun fmtK(n: Long): String = when { n >= 1_000_000 -> String.format("%.1fM", n / 1_000_000.0) n >= 1_000 -> "${n / 1000}K" diff --git a/app/src/main/java/ai/sonario/app/ui/SetupScreen.kt b/app/src/main/java/ai/sonario/app/ui/SetupScreen.kt index d814c08..c5c9509 100644 --- a/app/src/main/java/ai/sonario/app/ui/SetupScreen.kt +++ b/app/src/main/java/ai/sonario/app/ui/SetupScreen.kt @@ -1,5 +1,6 @@ package ai.sonario.app.ui +import ai.sonario.app.llm.ModelInfo import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,19 +14,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import ai.sonario.app.llm.ModelInfo /** - * First-run setup. Sonario needs one on-device model before it can summarize. - * This screen lets the user pick which model to download and shows live - * progress. It replaces the old manual adb-push step entirely. Once any model - * is present, the app routes straight to the summarizer on launch. + * First-run setup. The user can download one of three mobile-oriented local + * models or skip the large download and use Groq cloud. */ @Composable fun SetupScreen(vm: SummaryViewModel, onUseCloud: () -> Unit = {}) { val ui by vm.ui.collectAsState() val scroll = rememberScrollState() - val dl = ui.download + val download = ui.download Surface(color = SonarioColors.Deep, modifier = Modifier.fillMaxSize()) { Column( @@ -37,43 +35,75 @@ fun SetupScreen(vm: SummaryViewModel, onUseCloud: () -> Unit = {}) { ) { Spacer(Modifier.height(12.dp)) Row { - Text("Sonar", style = MaterialTheme.typography.headlineLarge, - color = SonarioColors.Ink) - Text("io", style = MaterialTheme.typography.headlineLarge, - color = SonarioColors.Green) + Text( + "Sonar", + style = MaterialTheme.typography.headlineLarge, + color = SonarioColors.Ink, + ) + Text( + "io", + style = MaterialTheme.typography.headlineLarge, + color = SonarioColors.Green, + ) } - Text("One quick setup step", + Text( + "Choose how local AI should feel", style = MaterialTheme.typography.titleLarge, color = SonarioColors.InkSoft, - modifier = Modifier.padding(top = 4.dp)) + modifier = Modifier.padding(top = 4.dp), + ) Text( - "Sonario runs the AI on your phone, so it needs a model file. " + - "Pick one to download now. You only do this once, and you can " + - "switch later in Models. Use Wi-Fi: these are 1 to 2 GB.", + "Pick the strongest, the most writing-focused, or the fastest local " + + "model. Use Wi-Fi: downloads range from about 1.6 to 4.3 GB.", style = MaterialTheme.typography.bodyMedium, color = SonarioColors.Muted, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 10.dp, bottom = 22.dp), ) - ui.models.forEach { m -> + ui.models.forEach { model -> ModelChoice( - model = m, - downloadingThis = dl.active && dl.model?.fileName == m.fileName, - fraction = if (dl.model?.fileName == m.fileName) dl.fraction else 0f, - bytes = if (dl.model?.fileName == m.fileName) dl.bytes else 0L, - total = if (dl.model?.fileName == m.fileName) dl.total else 0L, - anyDownloadActive = dl.active, - errorForThis = if (dl.model?.fileName == m.fileName) dl.error else null, - onDownload = { vm.downloadModel(m) }, + model = model, + downloadingThis = + download.active && download.model?.fileName == model.fileName, + fraction = if (download.model?.fileName == model.fileName) { + download.fraction + } else { + 0f + }, + bytes = if (download.model?.fileName == model.fileName) { + download.bytes + } else { + 0L + }, + total = if (download.model?.fileName == model.fileName) { + download.total + } else { + 0L + }, + anyDownloadActive = download.active, + errorForThis = if (download.model?.fileName == model.fileName) { + download.error + } else { + null + }, + onDownload = { vm.downloadModel(model) }, onCancel = { vm.cancelDownload() }, ) } + Spacer(Modifier.height(10.dp)) + Text( + "Models are stored only in Sonario's private app storage. Uninstalling " + + "Sonario removes completed and partial model downloads.", + style = MaterialTheme.typography.bodySmall, + color = SonarioColors.Muted, + textAlign = TextAlign.Center, + ) Spacer(Modifier.height(16.dp)) Text( - "On-device keeps everything on your phone but is slow. Prefer speed? " + - "You can use the Groq cloud instead (needs a free API key).", + "Local mode is private but still slower than cloud inference. Prefer " + + "speed and stronger results? Use Groq cloud instead.", style = MaterialTheme.typography.labelLarge, color = SonarioColors.Muted, textAlign = TextAlign.Center, @@ -82,8 +112,11 @@ fun SetupScreen(vm: SummaryViewModel, onUseCloud: () -> Unit = {}) { OutlinedButton( onClick = onUseCloud, colors = ButtonDefaults.outlinedButtonColors( - contentColor = SonarioColors.Green), - ) { Text("Use Groq cloud instead") } + contentColor = SonarioColors.Green, + ), + ) { + Text("Use Groq cloud instead") + } Spacer(Modifier.height(24.dp)) } } @@ -104,16 +137,24 @@ private fun ModelChoice( Surface( color = SonarioColors.Panel, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), ) { Column(Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text(model.label, color = SonarioColors.Ink, + Text( + model.label, + color = SonarioColors.Ink, fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleLarge) - Text("~${model.sizeMb} MB", color = SonarioColors.Green, - style = MaterialTheme.typography.labelLarge) + style = MaterialTheme.typography.titleLarge, + ) + Text( + "About ${formatSetupModelSize(model.sizeMb)}", + color = SonarioColors.Green, + style = MaterialTheme.typography.labelLarge, + ) } if (!downloadingThis) { Button( @@ -121,18 +162,25 @@ private fun ModelChoice( enabled = !anyDownloadActive, colors = ButtonDefaults.buttonColors( containerColor = SonarioColors.Green, - contentColor = SonarioColors.Abyss), + contentColor = SonarioColors.Abyss, + ), ) { - Icon(Icons.Filled.Download, contentDescription = null, - modifier = Modifier.size(18.dp)) + Icon( + Icons.Filled.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) Spacer(Modifier.width(6.dp)) Text("Get") } } } - Text(model.note, color = SonarioColors.Muted, + Text( + model.note, + color = SonarioColors.Muted, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 6.dp)) + modifier = Modifier.padding(top = 6.dp), + ) if (downloadingThis) { Spacer(Modifier.height(12.dp)) @@ -143,13 +191,16 @@ private fun ModelChoice( trackColor = SonarioColors.Panel2, ) Row( - Modifier.fillMaxWidth().padding(top = 8.dp), + Modifier + .fillMaxWidth() + .padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - val mb = bytes / 1_000_000 - val totalMb = total / 1_000_000 - Text("$mb / $totalMb MB", color = SonarioColors.InkSoft, - style = MaterialTheme.typography.labelLarge) + Text( + "${bytes / 1_000_000} / ${total / 1_000_000} MB", + color = SonarioColors.InkSoft, + style = MaterialTheme.typography.labelLarge, + ) Spacer(Modifier.weight(1f)) TextButton(onClick = onCancel) { Text("Cancel", color = SonarioColors.Muted) @@ -157,17 +208,27 @@ private fun ModelChoice( } } - errorForThis?.let { + errorForThis?.let { error -> Spacer(Modifier.height(10.dp)) - Text(it, color = SonarioColors.InkSoft, - style = MaterialTheme.typography.bodyMedium) + Text( + error, + color = SonarioColors.InkSoft, + style = MaterialTheme.typography.bodyMedium, + ) Spacer(Modifier.height(6.dp)) OutlinedButton( onClick = onDownload, colors = ButtonDefaults.outlinedButtonColors( - contentColor = SonarioColors.Green), - ) { Text("Retry") } + contentColor = SonarioColors.Green, + ), + ) { + Text("Retry") + } } } } } + +private fun formatSetupModelSize(sizeMb: Int): String = + if (sizeMb >= 1000) String.format("%.1f GB", sizeMb / 1000.0) + else "$sizeMb MB" diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..b61e74e --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + +