From c7b46ef0fae5635173b75deec8322eac2e0bc568 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 28 Jul 2026 13:52:26 -0500 Subject: [PATCH] feat(get-ai-models): Add "Get AI Models" plugin: GGUF catalog with checksum-gated downloads Bottom-drawer tab listing 11 curated fully-open GGUF files; downloads via DownloadManager to /sdcard/Download and keeps one only if its pinned SHA-256 matches. Verified files are remembered across restarts and revalidated by stat. --- .github/workflows/update-libs.yml | 1 + README.md | 1 + get-ai-models/.gitignore | 35 ++ get-ai-models/README.md | 167 +++++++ get-ai-models/build.gradle.kts | 86 ++++ get-ai-models/docs/CURATION.md | 120 +++++ get-ai-models/get-ai-models.html | 459 ++++++++++++++++++ get-ai-models/gradle.properties | 5 + get-ai-models/gradle/libs.versions.toml | 27 ++ get-ai-models/proguard-rules.pro | 11 + get-ai-models/settings.gradle.kts | 30 ++ get-ai-models/src/main/AndroidManifest.xml | 71 +++ .../src/main/assets/catalog/models.json | 107 ++++ get-ai-models/src/main/assets/docs/index.html | 225 +++++++++ get-ai-models/src/main/assets/icon_day.png | Bin 0 -> 6901 bytes get-ai-models/src/main/assets/icon_night.png | Bin 0 -> 7375 bytes .../getaimodels/GetAiModelsPlugin.kt | 266 ++++++++++ .../getaimodels/GetAiModelsRuntime.kt | 26 + .../getaimodels/catalog/ByteSize.kt | 20 + .../getaimodels/catalog/CatalogEntry.kt | 39 ++ .../getaimodels/catalog/CatalogLoader.kt | 77 +++ .../download/DownloadManagerClient.kt | 188 +++++++ .../getaimodels/download/DownloadState.kt | 101 ++++ .../getaimodels/download/ModelDownloader.kt | 416 ++++++++++++++++ .../getaimodels/download/ModelFileGate.kt | 129 +++++ .../getaimodels/download/Sha256.kt | 36 ++ .../download/VerifiedModelStore.kt | 114 +++++ .../getaimodels/net/NetworkStatus.kt | 40 ++ .../getaimodels/ui/ModelCatalogFragment.kt | 261 ++++++++++ .../getaimodels/ui/ModelRowAdapter.kt | 315 ++++++++++++ .../res/drawable/bg_cancel_progress_fill.xml | 20 + .../src/main/res/drawable/bg_cancel_track.xml | 10 + .../main/res/drawable/bg_delete_button.xml | 19 + .../main/res/drawable/bg_verify_button.xml | 22 + .../src/main/res/drawable/ic_download.xml | 10 + .../res/layout/fragment_model_catalog.xml | 26 + .../src/main/res/layout/item_model.xml | 199 ++++++++ .../src/main/res/values-night/colors.xml | 34 ++ get-ai-models/src/main/res/values/colors.xml | 51 ++ get-ai-models/src/main/res/values/strings.xml | 66 +++ get-ai-models/src/main/res/values/styles.xml | 49 ++ .../getaimodels/catalog/CatalogLoaderTest.kt | 87 ++++ .../getaimodels/download/DownloadStateTest.kt | 59 +++ .../download/DownloadedFileResolverTest.kt | 69 +++ .../getaimodels/download/ModelFileGateTest.kt | 156 ++++++ .../download/VerifiedModelStoreTest.kt | 58 +++ 46 files changed, 4308 insertions(+) create mode 100644 get-ai-models/.gitignore create mode 100644 get-ai-models/README.md create mode 100644 get-ai-models/build.gradle.kts create mode 100644 get-ai-models/docs/CURATION.md create mode 100644 get-ai-models/get-ai-models.html create mode 100644 get-ai-models/gradle.properties create mode 100644 get-ai-models/gradle/libs.versions.toml create mode 100644 get-ai-models/proguard-rules.pro create mode 100644 get-ai-models/settings.gradle.kts create mode 100644 get-ai-models/src/main/AndroidManifest.xml create mode 100644 get-ai-models/src/main/assets/catalog/models.json create mode 100644 get-ai-models/src/main/assets/docs/index.html create mode 100644 get-ai-models/src/main/assets/icon_day.png create mode 100644 get-ai-models/src/main/assets/icon_night.png create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/GetAiModelsPlugin.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/GetAiModelsRuntime.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/ByteSize.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogEntry.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoader.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadManagerClient.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadState.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelDownloader.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelFileGate.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/Sha256.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStore.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/net/NetworkStatus.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelCatalogFragment.kt create mode 100644 get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelRowAdapter.kt create mode 100644 get-ai-models/src/main/res/drawable/bg_cancel_progress_fill.xml create mode 100644 get-ai-models/src/main/res/drawable/bg_cancel_track.xml create mode 100644 get-ai-models/src/main/res/drawable/bg_delete_button.xml create mode 100644 get-ai-models/src/main/res/drawable/bg_verify_button.xml create mode 100644 get-ai-models/src/main/res/drawable/ic_download.xml create mode 100644 get-ai-models/src/main/res/layout/fragment_model_catalog.xml create mode 100644 get-ai-models/src/main/res/layout/item_model.xml create mode 100644 get-ai-models/src/main/res/values-night/colors.xml create mode 100644 get-ai-models/src/main/res/values/colors.xml create mode 100644 get-ai-models/src/main/res/values/strings.xml create mode 100644 get-ai-models/src/main/res/values/styles.xml create mode 100644 get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoaderTest.kt create mode 100644 get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadStateTest.kt create mode 100644 get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadedFileResolverTest.kt create mode 100644 get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/ModelFileGateTest.kt create mode 100644 get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStoreTest.kt diff --git a/.github/workflows/update-libs.yml b/.github/workflows/update-libs.yml index 530c1a14..9941877d 100644 --- a/.github/workflows/update-libs.yml +++ b/.github/workflows/update-libs.yml @@ -75,6 +75,7 @@ jobs: ["rainbow-on-the-go"]="rainbow-on-the-go.cgp" ["ai-literacy-course"]="ai-literacy-course.cgp" ["flutter-template"]="flutter-template.cgp" + ["get-ai-models"]="get-ai-models.cgp" ) for module in "${!MAP[@]}"; do src=$(ls "${module}/build/plugin/"*.cgp 2>/dev/null | head -n1) diff --git a/README.md b/README.md index 1c143330..f87d8452 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. | | [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. | | [`vector-search-plugin/`](vector-search-plugin/) | Semantic code search using embeddings and vector similarity. | +| [`get-ai-models/`](get-ai-models/) | Bottom-drawer catalog of curated, fully-open GGUF model files; downloads one to `/sdcard/Download` and verifies its SHA-256. | ## Building a plugin diff --git a/get-ai-models/.gitignore b/get-ai-models/.gitignore new file mode 100644 index 00000000..24227e33 --- /dev/null +++ b/get-ai-models/.gitignore @@ -0,0 +1,35 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log +.DS_Store + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.aab +*.apk +output-metadata.json + +# IntelliJ +*.iml +.idea/ + +# Keystore / signing material — never commit +*.jks +*.keystore +release.properties +signing.properties +keystore.properties + +# Google Services +google-services.json + +# Android Profiling +*.hprof diff --git a/get-ai-models/README.md b/get-ai-models/README.md new file mode 100644 index 00000000..e20e491c --- /dev/null +++ b/get-ai-models/README.md @@ -0,0 +1,167 @@ +# Get AI Models + +A Code On The Go plugin that adds a **Get AI Models** tab to the project editor's bottom drawer. The +tab lists a bundled catalog of small GGUF language-model files; each row downloads one `.gguf` +to `/sdcard/Download` and verifies its SHA-256. + +**Download only.** Loading or running a model is the job of a separate AI plugin built around +llama.cpp. This plugin never loads a model and never checks the device's RAM. It does keep one small +record — which entries passed the checksum — so a verified file is not forgotten on restart; see +[Remembering what is verified](#remembering-what-is-verified). + +## Build + +```sh +cd get-ai-models +../gradlew assemblePlugin # release .cgp -> build/plugin/get-ai-models.cgp +../gradlew assemblePluginDebug # debug variant +../gradlew test # re-validates the shipped catalog asset +``` + +Uses the shared repo-root Gradle wrapper and the shared repo-root `libs/` jars — it carries no +wrapper or jars of its own. A `local.properties` containing `sdk.dir=` is +required. + +Install the resulting `.cgp` through Code On The Go's Plugin Manager, then open a project and look in +the editor's bottom drawer. + +## Supported IDE versions + +`26.29`–`26.31` (`plugin.min_ide_version` / `plugin.max_ide_version`) — covers the current stable +release and the two before it. + +## What a row shows + +Each row is **one file** — a model at one quantization — so the same model can appear more than once. + +- Compact line: `parameters · quantization · on-disk size` +- Tap to expand: strengths-and-weaknesses paragraph, publisher, minimum RAM, licence, context window +- Minimum RAM is informational only; the device's memory is never read + +## What happens on Download + +1. **No connection** → blocked with a message, nothing queued. +2. **Metered connection** → warned with the file size; proceed or cancel. +3. **Transfer** → Android's `DownloadManager`. The Download button is replaced by a **Cancel** + control whose background fills as bytes arrive, and the status line reads e.g. + `Downloading 1.4 GB of 4.68 GB`. Tapping it cancels and discards the partial file. +4. **Paused is reported, not hidden.** A download started on an unmetered link will not roll over + onto cellular by itself — it pauses, and the row says `Paused — waiting for Wi-Fi`. Same for + `no connection` and `retrying`. +5. **Verification** → the finished file is streamed and hashed. Match: kept in `/sdcard/Download`. + Mismatch: **deleted**, with a re-download offered. + +Transfers and verification are plugin-scoped, so closing the tab mid-download does not abandon +either. + +### Approved deviation: progress on the row + +The ticket's locked decision is *"progress shown in the system notification... No in-app per-row +progress bar."* The row does show progress — as a fill behind the Cancel control — so **this is a +deviation, not a compliant reading of that rule.** + +It was requested and approved after on-device testing: the system notification is dismissible, and +once dismissed the row could not distinguish a slow download from one paused indefinitely waiting for +Wi-Fi. There is no separate progress-bar widget, but that is a mitigation, not compliance. + +## Remembering what is verified + +A passed checksum is recorded in the plugin's own SharedPreferences (`entryId → {absolutePath, +sizeBytes, verifiedAt}`), because hashing costs seconds to minutes and its result is not cheaply +re-derivable. On activation each record is revalidated with **one `stat`** — a remembered badge is +never shown without checking the disk still agrees: + +| On disk | Row shows | +| --- | --- | +| Path gone | `Download` — the record is dropped (you deleted or moved the file) | +| Present, size matches the record | `Downloaded` | +| Present, size differs | `Verify` — the checksum no longer holds, so it is not claimed | + +The size check catches every truncated or interrupted file for free. The one case it cannot see is a +file swapped for a *different* file of identical length, so an expanded row offers **Verify file +again**, which re-hashes on demand. A failed re-verify **does not delete** the file — it was not +written by this download, so removing it is the user's call, unlike the hard delete on a failed +*download*. + +### When Android reports no path + +Everything above needs the finished download's absolute path, which comes from DownloadManager's +`COLUMN_LOCAL_URI`. That column is not guaranteed to be a `file://` URI — on newer platforms it can be +`content://downloads/all_downloads/`, whose path (`/all_downloads/42`) is **not** a filesystem +path. Resolution therefore runs in three tiers: the `file://` URI, then `COLUMN_LOCAL_FILENAME` (which +still carries the real path), and finally the `content://` URI on its own. + +The third tier still verifies — the bytes are hashed through a `ContentResolver` — but there is no path +to record or delete, so that row shows `Downloaded` for the session, says the location is unknown, and +offers neither **Verify file again** nor **Delete file**. A checksum failure is still deleted, via +`DownloadManager.remove()`, which does not need a path. The download is never simply failed for want of +a path, which is what a naive `File(uri.path)` produced. + +### Approved deviation: this record is a model registry + +The ticket lists *"any 'installed' state, model registry"* as out of scope. This record is one, so it +is a deviation — **requested and approved by the ticket owner**, not an author decision, after a +verified 4 GB download lost its `Downloaded` badge on every IDE restart while the file sat on disk. +Keeping the badge without revalidating it would instead have made it lie once the file was deleted. + +It stays small and bounded: one JSON string per entry, cleared on uninstall. **Delete (below) extends +this same deviation** — it is what the record makes possible — rather than opening a new one. The +neighbouring exclusion, *"handling pre-existing files"*, is still honoured: nothing without a record +is ever read, verified, or deleted. + +## Deleting a downloaded file + +An expanded row with a recorded download also offers **Delete file** (red, destructive), which frees +the space and returns the row to `Download`. It asks first, naming the file and its full path. + +The safety rule: it deletes **only the recorded absolute path** — a file this plugin wrote and hashed +— never a path composed from the catalogued file name. A same-named file you put in +`/sdcard/Download` yourself is never a candidate, which also keeps the plugin out of "handling +pre-existing files". The record is dropped only once the file is actually gone, so a failed delete +leaves the row claiming what is still true. Deletion runs on the IO dispatcher and is refused while a +verification is hashing. + +Two caveats: if an AI plugin currently has the model loaded, the row clears but the space is not +reclaimed until that plugin releases the mapping; and the system Downloads list can keep showing the +file until Android rescans the folder. + +## Catalog + +A static JSON asset (`src/main/assets/catalog/models.json`). No remote catalog — a revised model list +ships as a new plugin version. + +The ticket's hard gates are: instruction-tuned; fully open licence verified on both the base model +and the GGUF re-upload; loads and generates coherent output in our AI plugin; passes the fixed +tool-use eval at ≥ 80%; valid chat template; single file from a reputable uploader with the SHA-256 +pinned to a specific revision; context window ≥ 16k tokens. Quantization is *not* restricted. + +**The current catalog does not satisfy all of them.** The maintainer selected these six models +explicitly, waiving the instruction-tuned, open-licence and 16k-context gates for specific entries — +so `CatalogLoader` no longer enforces the licence or context gates, and the unit tests no longer +assert them. `docs/CURATION.md` records exactly which gate each entry breaks. + +Version 1.0.0 ships 6 files, all under 1 GB: Qwen2.5 0.5B (base), Qwen3 0.6B, Qwen3.5 0.8B, +Llama 3.2 1B Instruct, SmolLM2 360M Instruct, and H2O Danube3 500M (base). Five are Apache-2.0; +Llama 3.2 1B is under the Llama 3.2 Community Licence. + +**The two behavioural gates are not yet proven.** Per the ticket, the admission harness that runs a +candidate through the AI plugin is downstream work, so the shipped catalog is the set of candidates +clearing every statically checkable gate. See [`docs/CURATION.md`](docs/CURATION.md) for the +per-entry evidence, the rejected candidates and why, and the procedure for adding an entry. + +## In-IDE help + +Long-press the tab, any row, or the Download / Cancel / Verify / Delete controls for Tier 1/Tier 2 +tooltips (six entries); the tab's tooltip links to the full offline guide bundled at +`src/main/assets/docs/index.html`. Registered through `DocumentationExtension` under the category +`plugin_org.appdevforall.getaimodels`. + +## Permissions + +`network.access` (the HTTPS fetch), `filesystem.read` (re-read the download to hash it), and +`filesystem.write` (delete a download that fails the checksum, or one the user asks to remove). No +native code, no system commands, no IDE settings, no project-structure access. + +## Full documentation + +[`get-ai-models.html`](get-ai-models.html) — overview, architecture, and rationale. diff --git a/get-ai-models/build.gradle.kts b/get-ai-models/build.gradle.kts new file mode 100644 index 00000000..5d12ab9a --- /dev/null +++ b/get-ai-models/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "get-ai-models" +} + +android { + namespace = "org.appdevforall.getaimodels" + compileSdk = 36 + + defaultConfig { + applicationId = "org.appdevforall.getaimodels" + minSdk = 21 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // The unit tests parse src/main/assets/catalog/models.json off the filesystem, so the + // JVM test task needs the module directory as its working directory. + testOptions { + unitTests.all { + it.workingDir = projectDir + } + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } +} + +dependencies { + compileOnly(files("../libs/plugin-api.jar")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlin.stdlib) + + testImplementation(libs.junit) + testImplementation(libs.json) // real org.json for JVM unit tests (android.jar's is stubbed) + testImplementation(files("../libs/plugin-api.jar")) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +// The application-as-library packaging these plugins use trips the AAR metadata checks. +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} diff --git a/get-ai-models/docs/CURATION.md b/get-ai-models/docs/CURATION.md new file mode 100644 index 00000000..1a2ea8c2 --- /dev/null +++ b/get-ai-models/docs/CURATION.md @@ -0,0 +1,120 @@ +# Catalog curation record + +Why each row in `src/main/assets/catalog/models.json` is there, and what was checked. Update this +file in the same change that edits the catalog. + +The catalog is admitted by checklist, not by taste. A candidate `(model, quantization)` file earns a +row only by passing every hard gate: + +| # | Gate | How it is proven | +|---|---|---| +| 1 | Instruction-tuned, not a base model | The base repository is the vendor's `-Instruct` / instruct-tuned release | +| 2 | Fully open licence, no restrictions, on the base model **and** the GGUF re-upload | `cardData.license` on both Hugging Face repositories | +| 3 | Loads and generates coherent output in our AI plugin | **Admission test — downstream work** | +| 4 | Passes the fixed tool-use eval at >= 80% | **Admission test — downstream work** | +| 5 | Valid chat template, embedded in or known for the GGUF | `chat_template` in the base repo's `tokenizer_config.json`, or a `chat_template.jinja` | +| 6 | Single file from a reputable uploader, sha256 pinned to a specific revision | Hugging Face tree API: one `.gguf`, no `-00001-of-000NN` parts; URL resolves a commit sha, not `main` | +| 7 | Context window >= 16k tokens | `max_position_embeddings` in the base repo's `config.json` | + +Quantization is **not** restricted. `Q4_K_M` and `Q8_0` both appear below; `Q4_0` would be equally +eligible. The gates are about behaviour, not about a preferred format. + +## Status of gates 3 and 4 + +Gates 3 and 4 are behavioural, and the ticket places the harness that proves them outside this +plugin: "a repeatable harness runs a candidate through the project's AI plugin (the llama.cpp +wrapper) ... Building this harness is downstream work, not part of the download plugin." + +So the shipped catalog is **the set of candidates that clear every statically checkable gate +(1, 2, 5, 6, 7)**. Gates 3 and 4 are unproven — *not failed* — for every entry below: none has been +run through the harness, so none has been shown either to work or to break. + +This is surfaced in the product, not just here. Every entry carries +`"behaviouralGatesVerified": false`, the field is **required** by `CatalogLoader` so a new entry +cannot omit it, and an expanded row shows *"Not yet validated in the AI plugin"* while it is false. A +unit test asserts no entry claims otherwise. + +**Unresolved contradiction for the team.** The ticket requires gates 3 and 4 for admission *and* +places the harness that proves them outside this plugin's scope. Both cannot hold while a catalog +ships: the options are to accept the flagged entries as provisional (current state), hold the plugin +until the harness exists, or ship with an empty catalog — which would fail the ticket's own +definition of done ("downloads + verifies a file"). When the harness exists, run it over this list, +flip the flag per entry, and remove anything that fails. + +## Verification method + +Metadata came from the Hugging Face API, not from model cards or memory: + +- `GET /api/models/{repo}` — `sha` (the revision to pin), `cardData.license`, `gated` +- `GET /api/models/{repo}/tree/{sha}?recursive=true` — the exact `size` and `lfs.oid` per file. + For an LFS file **`lfs.oid` is the SHA-256 of the file content**, which is where each catalog + `sha256` comes from — no multi-gigabyte download was needed to obtain it. +- `GET /{base}/resolve/main/config.json` — `max_position_embeddings` (gate 7) +- `GET /{base}/resolve/main/tokenizer_config.json` (or `chat_template.jinja`) — gate 5 +- `HEAD` on each pinned `resolve/{sha}/{file}` URL — confirmed HTTP 200 with `Content-Length` + equal to the catalogued `sizeBytes` and `X-Linked-Etag` equal to the catalogued `sha256`. + +All 12 entries passed that final HEAD cross-check, re-run after the most recent edit. + +## Catalog entries (maintainer-selected) + +**This catalog is a maintainer override, not a gate-derived list.** The six models below were named +explicitly by the ticket owner, replacing the eleven gate-passing entries that shipped earlier. Three +of the six break at least one hard gate; those gates are therefore **no longer enforced** in +`CatalogLoader` (licence, 16k context) nor asserted in `CatalogLoaderTest`. + +| Entry id | Base model | GGUF uploader | Ctx | Licence | Gate waived | +|---|---|---|---|---|---| +| `qwen2-5-0-5b-q4_k_m` | `Qwen/Qwen2.5-0.5B` | QuantFactory | 32768 | apache-2.0 | **1** (base model) | +| `qwen3-0.6b-q8_0` | `Qwen/Qwen3-0.6B` | Qwen (Alibaba Cloud) | 40960 | apache-2.0 | none | +| `qwen3.5-0.8b-q4_k_m` | `Qwen/Qwen3.5-0.8B` | unsloth | 262144 | apache-2.0 | none | +| `llama-3-2-1b-instruct-q4_k_m` | `meta-llama/Llama-3.2-1B-Instruct` | unsloth | 131072 | llama3.2 | **2** (Llama 3.2 Community Licence; upstream repo gated) | +| `smollm2-360m-instruct-q8_0` | `HuggingFaceTB/SmolLM2-360M-Instruct` | Hugging Face TB | 8192 | apache-2.0 | **7** (8192 context) | +| `h2o-danube3-500m-base-q4_k_m` | `h2oai/h2o-danube3-500m-base` | mradermacher | 8192 | apache-2.0 | **1** (base model), **7** (8192 context) | + +Gates 3 and 4 remain unproven for all six, as before. Gate 5 (chat template) does not apply to the +two base models - they have none, which is part of why they are not usable as assistants. Gate 6 +holds for every entry: single file, sha256 pinned to a commit revision, HEAD-verified. + +Two entries have no GGUF published by the model's own author, so a third-party conversion is used: +`QuantFactory` for Qwen2.5-0.5B and `mradermacher` for Danube3-500m-base. Both declare the base +model's Apache-2.0 licence. Qwen3.5-0.8B likewise uses `unsloth`. + +### Removed at the owner's request + +The previous eleven entries (Qwen3 1.7B/4B/8B, Qwen2.5 1.5B, Qwen2.5-Coder 1.5B/7B, Granite 4.0 Micro, +Granite 3.3 2B, SmolLM3 3B, Mistral 7B v0.3 — plus Qwen3 0.6B and Qwen3.5 0.8B, which were kept) all +passed gates 1, 2, 5, 6 and 7. They were removed because the catalog was narrowed to the list above, +not because any of them failed a check. Their verified metadata is recoverable from git history. + +## Rejected candidates + +Recorded so the same candidates are not re-litigated. None of these are catalog rows. + +Each row's evidence was read from the same API, not from recollection. + +| Candidate | Gate failed | Detail | +|---|---|---| +| `meta-llama/Llama-3.2-3B-Instruct` (Llama family) | 2 | `license: llama3.2`, `gated: manual`. Community licence with use restrictions; not fully open. | +| `google/gemma-3-4b-it` (Gemma family) | 2 | `license: gemma`, `gated: manual`. Subject to Gemma terms of use. | +| `Qwen/Qwen2.5-3B-Instruct` | 2 | `license: other`, `license_name: qwen-research` — unlike its Apache-2.0 1.5B and 7B siblings. | +| `tiiuae/Falcon3-3B-Instruct` | 2 | `license: other`, `license_name: falcon-llm-license`; carries an acceptable-use policy. | +| `bartowski/microsoft_Phi-4-mini-instruct-GGUF` | 2 | The base model is MIT, but the re-upload repository declares no licence at all. Gate 2 requires the licence on **both**, so it is out until the re-upload states one. | +| `HuggingFaceTB/SmolLM2-1.7B-Instruct` | 7 | `max_position_embeddings: 8192`. | +| `allenai/OLMo-2-1124-7B-Instruct` | 7 | `max_position_embeddings: 4096`. | +| `openai/gpt-oss-20b` | — | Apache-2.0, ungated, 131072 context: it clears every statically checkable gate. Held back on size — roughly 12 GB at MXFP4 is not usable on the phones this plugin targets — pending the deferred runtime-budget gate. Not a licence rejection. | +| `google/gemma-3-270m` | 2, plus unavailable | `license: gemma`, `gated: manual`. **Requested by the owner but not addable:** the repo ships no GGUF, and the only GGUF conversions are of the `-it` variant, which is a different model. Substituting it was not authorised. | +| Any split GGUF (`-00001-of-00002`) | 6 | Explicitly single-file only, so one checksum covers the whole model. | + +## Adding an entry + +1. Confirm gates 1, 2, 5 and 7 from the base repository, and gate 2 again on the GGUF repository. +2. Read the GGUF repo's current commit `sha`, then take `size` and `lfs.oid` for the one file you + want from `/api/models/{repo}/tree/{sha}?recursive=true`. +3. Build the URL as `https://huggingface.co/{repo}/resolve/{sha}/{file}` — a commit sha, never + `main`, or a re-upload would break the pinned checksum. +4. `HEAD` that URL and confirm `Content-Length` matches `size` and `X-Linked-Etag` matches + `lfs.oid`. +5. Write the strengths-and-weaknesses paragraph for **that quantization**, not for the model family, + and say what it is bad at. +6. Add the row here, then run `../gradlew test` — `CatalogLoaderTest` re-validates the whole asset. diff --git a/get-ai-models/get-ai-models.html b/get-ai-models/get-ai-models.html new file mode 100644 index 00000000..0d111c01 --- /dev/null +++ b/get-ai-models/get-ai-models.html @@ -0,0 +1,459 @@ + + + + + + Get AI Models - Code On The Go plugin + + + + +

Get AI Models

+

A Code On The Go plugin that adds a "Get AI Models" tab to the project editor's +bottom drawer, listing a curated catalog of small GGUF language-model files and downloading them to +the device with a SHA-256 gate.

+ +

Executive overview

+

Running a language model locally on an Android device starts with getting a model file onto the +device. Doing that by hand means finding a trustworthy GGUF upload, judging whether its licence +actually permits use, picking a quantization, downloading several gigabytes over a browser, and +hoping the bytes arrived intact. That is a poor first experience, and it is the step where a user +most easily ends up with an unusable file or a licence they are not allowed to rely on.

+ +

This plugin removes that work. It ships a static, curated catalog of model files, each one +screened against an explicit checklist - fully open licence, instruction-tuned, a real chat template, +a single-file download from a reputable uploader with the checksum pinned to a specific upload +revision, and at least a 16k-token context window - with any waiver recorded per entry. One tap +downloads the file through +Android's own download manager, and the plugin verifies its SHA-256 before letting the user keep +it. A file that fails verification is deleted rather than left to fail mysteriously later.

+ +
+ Scope: download only. Loading and running a model is the job of a separate AI plugin + built around llama.cpp. This plugin fetches and verifies files; it never loads one. +
+ +

Core functionality

+ +

The catalog

+

A static JSON asset bundled inside the plugin. There is no remote or updatable catalog - a +revised model list ships as a new plugin version, which keeps the checksums, the URLs, and the +descriptions versioned together as one reviewed artefact.

+

Granularity is one row per (model, quantization) file, not per model, because the two +choices cannot be judged separately: a 4-bit build of a larger model and an 8-bit build of a smaller +one are genuinely different products. Each entry carries a name, quantization, parameter count, +exact on-disk byte size, a strengths-and-weaknesses paragraph, the direct .gguf URL, a +SHA-256, a minimum-RAM figure, and the publisher.

+

What ships in version 1.0.0:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelQuantParamsOn diskContextLicencePublisher
Qwen2.5 0.5B (base)Q4_K_M0.5B0.37 GB32kapache-2.0QuantFactory
Qwen3 0.6BQ8_00.6B0.60 GB40kapache-2.0Qwen (Alibaba Cloud)
Qwen3.5 0.8BQ4_K_M0.8B0.50 GB256kapache-2.0unsloth
Llama 3.2 1B InstructQ4_K_M1B0.75 GB128kllama3.2unsloth
SmolLM2 360M InstructQ8_0360M0.36 GB8kapache-2.0Hugging Face TB
H2O Danube3 500M (base)Q4_K_M500M0.30 GB8kapache-2.0mradermacher
+

Quantization is deliberately not restricted: Q4_K_M and Q8_0 both appear +because the gates are about behaviour, not about a preferred format.

+

This list is a maintainer override, not a gate-derived set. Five entries are Apache-2.0; +Llama 3.2 1B is under the Llama 3.2 Community Licence, whose upstream repo is also access-gated. Two +entries are pretrained base models with no chat template, and two have an 8k context window. +The licence and context gates are consequently no longer enforced in code - see +docs/CURATION.md for the per-entry waiver record.

+ +

Admission gates

+

Curation is acceptance-criteria-first. A candidate file earns a row only by passing all of:

+
    +
  1. Instruction-tuned, not a base model.
  2. +
  3. Fully open licence with no restrictions, verified on the base model and on the GGUF + re-uploader's repository.
  4. +
  5. The exact file loads and generates coherent output in our llama.cpp-wrapper build.
  6. +
  7. It passes a fixed tool-calling evaluation at 80% or better.
  8. +
  9. A valid chat template, embedded in or documented for the GGUF.
  10. +
  11. A single-file download from a reputable uploader, SHA-256 pinned to a specific revision.
  12. +
  13. A context window of at least 16k tokens.
  14. +
+

Gates 1, 2, 5, 6 and 7 are verifiable from published metadata and are recorded per entry in +docs/CURATION.md; three of them (single-file, open licence, 16k context) are also +asserted by a unit test that reads the shipped catalog. Gates 3 and 4 are behavioural and are +proven by a repeatable admission test that runs the candidate through the AI plugin - that harness +is downstream work, so the shipped catalog is the set of candidates that clear every statically +checkable gate. A runtime budget (peak RAM, tokens per second) is deliberately not a gate yet.

+ +

Downloading and verification

+
    +
  • No connectivity blocks the download with a message; nothing is queued.
  • +
  • A metered connection warns first, showing the file size, with proceed and cancel. + A download that starts on an unmetered link is queued with + setAllowedOverMetered(false), so dropping to cellular pauses the transfer rather + than silently spending the user's data allowance.
  • +
  • Transfer is Android's DownloadManager, so progress is shown in the + system notification - and, because that notification is dismissible, on the row as well: the + Download button is replaced by a Cancel control whose background is the progress fill, + with byte counts in the status line. There is still no separate progress-bar widget, and + cancelling discards the partial file.
  • +
  • A stalled transfer is named, not hidden. Pending, running and each paused reason + (waiting for Wi-Fi, no connection, retrying) render distinctly, so a download paused indefinitely + cannot masquerade as one making progress.
  • +
  • Destination is always /sdcard/Download.
  • +
  • SHA-256 is a hard gate. On completion the file is streamed and hashed. A match keeps + it; a mismatch deletes it and offers a re-download.
  • +
+ +

Reporting an in-flight transfer

+

The ticket specifies progress in the system notification and no in-app progress bar. That holds +only while the notification exists - and the user can swipe it away, after which the row was the sole +remaining signal and said nothing useful. Worse, STATUS_PAUSED originally shared a silent +branch with RUNNING, so a download parked indefinitely waiting for Wi-Fi looked identical +to one downloading normally.

+

The chosen mitigation avoids adding a separate progress widget by making the Cancel control its +own indicator: a <clip> drawable behind the label, driven by +setLevel(0..10000), so no measured width or layout pass is needed. The status line +carries the numbers and the pause reason. When DownloadManager reports an unknown total +size, the catalogued byte count is used as the denominator - it is the figure the file will be +verified against anyway.

+ +

Deleting a downloaded file

+

An expanded row backed by a persisted record offers a destructive Delete file action behind +a confirmation that names the file and its full path. The safety rule is narrow and load-bearing: it +deletes only the recorded absolute path - a file this plugin wrote and hashed - and never a +path composed from the catalogued file name, so a same-named file the user placed in Downloads is +never a candidate. That is also what keeps the feature clear of the ticket's "handling pre-existing +files" exclusion.

+

The record is dropped only once the file is actually gone, so a failed delete leaves the row +asserting what is still true. A delete is refused while a verification is hashing, since removing the +file mid-stream would surface as a confusing read error. Two limits are documented rather than worked +around: space is not reclaimed while another plugin holds the file mapped, and the system Downloads +listing can lag until Android rescans.

+ +

The tab UI

+

A compact row per file - name plus a parameters · quantization · size +spec line - with tap-to-expand detail showing the strengths-and-weaknesses paragraph, the publisher, +the minimum RAM, and the licence and context window. Minimum RAM is display-only; the device's +memory is never read, so the user is informed rather than gated.

+ +
+ Explicitly out of scope: running or loading models; device-RAM gating; handling + user-supplied checksums; in-app progress UI; a remote catalog. +

+ Two approved deviations, both requested by the ticket owner during review rather than + chosen by the author: +

+ 1. A minimal model registry. The ticket excludes any "installed" state or model registry. + One is present, because without it a verified Downloaded badge vanished on every IDE + restart while a 4 GB file sat on disk - and simply keeping the badge would have made it lie once + the user deleted that file. What is stored is only "this entry's checksum passed, for the file at + this path and length", revalidated on every activation. It never blocks a download, and the one + thing it manages is deleting a file it downloaded itself, on request. The neighbouring exclusion, + "handling pre-existing files", is still honoured: nothing without a record is read or deleted. +

+ 2. Progress on the row. The ticket says progress belongs in the system notification, with + no in-app per-row progress bar. The row shows a fill behind the Cancel control, so this is a + deviation and not a compliant reading: the notification is dismissible, and once dismissed the row + could not tell a slow download from one paused indefinitely. No separate progress widget was added, + but that is a mitigation, not compliance. +

+ See Remembering a verification, without letting it lie, Reporting an in-flight + transfer, and Deleting a downloaded file below. +
+ +

Technical architecture

+ +

Shape

+

A standard Code On The Go plugin: an Android application module that installs as a library, +applying com.itsaky.androidide.plugins.build and packaged by +assemblePlugin into build/plugin/get-ai-models.cgp. The IDE API arrives as +compileOnly(files("../libs/plugin-api.jar")) from the shared repo-root +libs/, and the build uses the shared repo-root Gradle wrapper.

+ +

Extension points

+

GetAiModelsPlugin implements three interfaces:

+
    +
  • IPlugin - the initialize → activate → deactivate → + dispose lifecycle. The downloader is created in activate() and torn down in + both deactivate() and dispose().
  • +
  • UIExtension.getEditorTabs() - contributes the single bottom-drawer + TabItem, whose factory produces a fresh ModelCatalogFragment.
  • +
  • DocumentationExtension - registers three tooltip entries (tab, row, download + button) under the category plugin_org.appdevforall.getaimodels, each with a Tier 1 + summary and Tier 2 detail, and points Tier 3 at assets/docs/index.html.
  • +
+ +

Components

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileResponsibility
GetAiModelsPlugin.ktLifecycle, tab registration, tooltip and Tier 3 docs wiring.
GetAiModelsRuntime.ktHands the plugin-scoped downloader to fragments the IDE constructs itself. Cleared on + teardown, so an unloaded plugin leaves nothing behind.
catalog/CatalogLoader.ktReads and strictly validates the bundled JSON: 64-hex SHA-256, HTTPS-only URL, no path + separator in the file name, positive sizes, and the 16k context gate.
download/ModelDownloader.ktEnqueues to DownloadManager, polls for completion, resolves where the file + actually landed, and enforces the checksum gate. Exposes a + StateFlow of per-row state and a SharedFlow of one-shot + outcomes.
download/VerifiedModelStore.ktPersists which entries passed the checksum, and the stat-based revalidation + (DiskCheck) that decides whether a remembered verification still holds. Every + method suspends onto Dispatchers.IO.
download/Sha256.ktStreams the file in 1 MiB chunks; cancellation-aware so teardown mid-verify stops + promptly.
net/NetworkStatus.ktClassifies the active network as unavailable, metered, or unmetered.
ui/ModelCatalogFragment.ktThe tab: list, network gates, metered confirmation, and the re-download prompt.
ui/ModelRowAdapter.ktCompact rows, tap-to-expand detail, and per-row button state.
+ +

Why download state is plugin-scoped, not fragment-scoped

+

A model file is gigabytes. Closing the bottom drawer must not abandon a transfer, and the +checksum gate has to run even when nobody is watching the tab. So ModelDownloader +lives for the plugin's lifetime and the fragment is a pure view over its +StateFlow. Reopening the tab mid-download shows the correct state.

+ +

Remembering a verification, without letting it lie

+

Two distinct problems hide in "keep the Downloaded badge across restarts". Forgetting: +hashing costs seconds to minutes, and the result is not cheaply re-derivable, so it has to be stored. +Lying: anything stored is a claim about the past, and the file can be deleted or replaced +behind the plugin's back. Solving only the first yields a badge that outlives the file it +describes.

+

So VerifiedModelStore persists entryId → {absolutePath, sizeBytes, +verifiedAt} in the plugin's own SharedPreferences, and ModelDownloader.restore() +revalidates every record with a single stat on activation:

+ + + + + + + + + + + + + + + + + +
DiskRow stateWhy
Path absentIdle, record deletedThe user removed the file outside the IDE.
Present, length == recorded sizeVerifiedSame path and same length as when the SHA-256 passed.
Present, length differsChangedThe recorded checksum no longer holds, so it is not claimed; the row offers + re-verification instead.
+

Storing the absolute path rather than the catalogued file name also closes the +-1.gguf blind spot: a record points at the file DownloadManager really wrote. The size +check is free and catches every truncated file; the case it cannot catch is a same-length +substitution, which is why an expanded verified row keeps an explicit Verify file again action +that re-hashes on demand. A failed re-verify never deletes - unlike a failed download, that file was +not written by us.

+

This is knowingly the "installed state / model registry" the ticket lists as out of scope: there +is no way to survive a restart without crossing that line, and a status that lies after a restart was +the worse trade. The registry stays bounded - one small JSON string per entry, at most one per catalog +row, cleared on uninstall.

+ +

Threading: why none of this can ANR or race

+
    +
  • No disk on the main thread. Every VerifiedModelStore method is + suspend and wraps its work in withContext(Dispatchers.IO), so the API + shape makes a main-thread call impossible. That matters because the first read of a + SharedPreferences instance blocks until its backing file has loaded.
  • +
  • Lazy, single initialisation. The plugin passes a provider lambda rather than an + open SharedPreferences, because activate() runs on the main thread. The provider is + invoked on IO behind a Mutex, so concurrent first callers cannot each build an + instance.
  • +
  • No fsync stalls. Writes use apply(): the in-memory map updates + immediately and the disk write is asynchronous.
  • +
  • One source of truth, mutated atomically. Row state lives only in + MutableStateFlow and is only ever changed through update {}, so + concurrent verifications cannot lose each other's writes.
  • +
  • Restore cannot clobber a live row. The rebuilt map is merged in a single + update {} that keeps any entry already carrying state - so a Download tapped while + the disk was being read wins over the restored value.
  • +
  • No duplicate work per row. Both enqueue and + verifyExisting return early when that entry is already downloading or verifying.
  • +
  • Hashing never blocks the poll loop and is cancellation-aware, so plugin teardown stops + it between 1 MiB chunks rather than after gigabytes.
  • +
+ +

Completion detection

+

Completion is detected by polling DownloadManager.query() every 1.5 seconds rather +than by registering a BroadcastReceiver for +ACTION_DOWNLOAD_COMPLETE. Polling keeps the plugin off the host process's dynamic +receiver surface (a plugin's package id is not a real installed UID, which makes host-process +registration a sharp edge worth avoiding) and costs nothing while idle: the poll loop only exists +while a download is tracked, and it exits as soon as the last one finishes. Hashing runs in its own +coroutine so a second download's completion is still noticed promptly.

+ +

Resource discipline

+

One CoroutineScope(SupervisorJob() + Dispatchers.IO), owned by +ModelDownloader and cancelled in dispose(). Cursors and input streams are +closed with use {}. The fragment collects flows inside +repeatOnLifecycle(STARTED) and nulls its view references in +onDestroyView(). The plugin's only long-lived mutable state is the single downloader +reference in GetAiModelsRuntime, which holds the application context - never an +Activity or a View - and is cleared on teardown.

+ +

Permissions

+

Three, all on live code paths: network.access (the HTTPS fetch), +filesystem.read (re-read the download to hash it), and +filesystem.write (delete a download that fails the checksum). No system commands, no +IDE settings, no project structure access, no native code, and no bundled .so +libraries.

+ +

Theming and accessibility

+

Layout inflation is routed through PluginFragmentHelper.getPluginInflater, and +PluginTheme extends Theme.Material3.DayNight.NoActionBar with a +values-night/colors.xml that mirrors values/colors.xml name for name, so +the tab follows the IDE's light/dark setting. Messages use Snackbar rather than +Toast: a Toast raised from a plugin context adds a window under a package id that is +not a real installed UID and takes the IDE down with a SecurityException.

+ +

Usage

+ +

Build

+
cd get-ai-models
+../gradlew assemblePlugin        # release .cgp -> build/plugin/get-ai-models.cgp
+../gradlew assemblePluginDebug   # debug variant
+../gradlew test                  # validates the shipped catalog asset
+

The plugin uses the shared repo-root Gradle wrapper and the shared repo-root +libs/ jars; it carries no wrapper or jars of its own. A +local.properties with sdk.dir= is required.

+ +

Install

+

Copy the .cgp to the device and install it through Code On The Go's Plugin Manager, +then open a project.

+ +

Use

+
    +
  1. Open the editor's bottom drawer and select Get AI Models.
  2. +
  3. Tap a row to read what that file is good and bad at.
  4. +
  5. Tap Download. On a metered connection, confirm the warning.
  6. +
  7. Watch progress in the system notification area.
  8. +
  9. The row reads Verifying… and then Downloaded. The file is in + /sdcard/Download.
  10. +
+

Long-press the tab, any row, or a Download button for in-IDE help; the tab's tooltip links to +the full offline guide.

+ +

Key benefits

+
    +
  • Licence transparency. Every entry's licence is verified on both the base model and the + re-upload before it ships, and shown on the row. The current catalog does include one + restricted-licence model at the maintainer's request, so the guarantee is disclosure rather than + exclusion.
  • +
  • Integrity is not optional. A pinned SHA-256 verified on device turns "the download + probably worked" into a yes or no answer, and a corrupt multi-gigabyte file is deleted at the + point of failure instead of surfacing later as an inference crash.
  • +
  • Revision pinning. URLs resolve a specific upload revision, not a branch, so the + checksum in the catalog cannot be invalidated by the uploader replacing a file.
  • +
  • Honest guidance. Each row states what its file is bad at, not only what it is good + at, and the minimum-RAM figure informs the user rather than silently blocking a device the + plugin has not actually measured.
  • +
  • No surprise data charges. Metered connections warn with the file size before + anything starts, and a Wi-Fi download does not roll over onto cellular by itself.
  • +
  • Cheap to update, easy to review. The catalog is one JSON asset guarded by unit + tests; adding or removing a model is a reviewable diff with no server to operate.
  • +
  • Modest footprint. No native binaries, no reflection into IDE internals, no + background work while idle, and three narrowly scoped permissions.
  • +
+ + + diff --git a/get-ai-models/gradle.properties b/get-ai-models/gradle.properties new file mode 100644 index 00000000..21a36623 --- /dev/null +++ b/get-ai-models/gradle.properties @@ -0,0 +1,5 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2560m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.caching=true diff --git a/get-ai-models/gradle/libs.versions.toml b/get-ai-models/gradle/libs.versions.toml new file mode 100644 index 00000000..03f0ddab --- /dev/null +++ b/get-ai-models/gradle/libs.versions.toml @@ -0,0 +1,27 @@ +[versions] +# Must match the kotlin-gradle-plugin classpath pinned in settings.gradle.kts. +kotlin = "2.1.0" +coreKtx = "1.13.1" +appcompat = "1.6.1" +material = "1.12.0" +recyclerview = "1.3.2" +fragment = "1.8.8" +lifecycle = "2.6.2" +coroutines = "1.6.4" +junit = "4.13.2" +json = "20240303" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } + +# Test +junit = { group = "junit", name = "junit", version.ref = "junit" } +json = { group = "org.json", name = "json", version.ref = "json" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/get-ai-models/proguard-rules.pro b/get-ai-models/proguard-rules.pro new file mode 100644 index 00000000..6ade8114 --- /dev/null +++ b/get-ai-models/proguard-rules.pro @@ -0,0 +1,11 @@ +# Minification is disabled for this plugin (see build.gradle.kts), so these rules are only a +# safety net if it is ever turned on. + +# The IDE loads the plugin entry point by name from AndroidManifest's plugin.main_class. +-keep class org.appdevforall.getaimodels.GetAiModelsPlugin { *; } + +# Fragments contributed to the IDE are instantiated by the host. +-keep class org.appdevforall.getaimodels.ui.** { *; } + +# plugin-api is provided by the IDE at runtime (compileOnly). +-dontwarn com.itsaky.androidide.plugins.** diff --git a/get-ai-models/settings.gradle.kts b/get-ai-models/settings.gradle.kts new file mode 100644 index 00000000..4a534b5d --- /dev/null +++ b/get-ai-models/settings.gradle.kts @@ -0,0 +1,30 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "GetAiModelsPlugin" diff --git a/get-ai-models/src/main/AndroidManifest.xml b/get-ai-models/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7d27bd2a --- /dev/null +++ b/get-ai-models/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/get-ai-models/src/main/assets/catalog/models.json b/get-ai-models/src/main/assets/catalog/models.json new file mode 100644 index 00000000..bcf56fa2 --- /dev/null +++ b/get-ai-models/src/main/assets/catalog/models.json @@ -0,0 +1,107 @@ +{ + "catalogVersion": 1, + "models": [ + { + "id": "qwen2-5-0-5b-q4_k_m", + "name": "Qwen2.5 0.5B (base)", + "quantization": "Q4_K_M", + "parameters": "0.5B", + "fileName": "Qwen2.5-0.5B.Q4_K_M.gguf", + "sizeBytes": 397807488, + "sha256": "b556a76bfad26a83bc59c1c981bc3c72b88978cc21bb6e6321f7168ff9f22f38", + "url": "https://huggingface.co/QuantFactory/Qwen2.5-0.5B-GGUF/resolve/808e875628eb5b8d1e30728425039f4067b6ae16/Qwen2.5-0.5B.Q4_K_M.gguf", + "minRamBytes": 2147483648, + "publisher": "QuantFactory - GGUF of Qwen/Qwen2.5-0.5B", + "contextTokens": 32768, + "license": "apache-2.0", + "baseModel": "Qwen/Qwen2.5-0.5B", + "description": "A pretrained base model, NOT instruction-tuned - it continues text rather than following instructions, and has no chat template, so a chat UI will get completions rather than answers. Strengths: the smallest file in the catalog and near-instant to load. Weaknesses: not usable as an assistant without further fine-tuning, and no tool-call ability at all. Listed at the ticket owner's request; it does not meet the instruction-tuned gate.", + "behaviouralGatesVerified": false + }, + { + "id": "qwen3-0.6b-q8_0", + "name": "Qwen3 0.6B", + "quantization": "Q8_0", + "parameters": "0.6B", + "fileName": "Qwen3-0.6B-Q8_0.gguf", + "sizeBytes": 639446688, + "sha256": "9465e63a22add5354d9bb4b99e90117043c7124007664907259bd16d043bb031", + "url": "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/23749fefcc72300e3a2ad315e1317431b06b590a/Qwen3-0.6B-Q8_0.gguf", + "minRamBytes": 3221225472, + "publisher": "Qwen (Alibaba Cloud) - official GGUF release", + "contextTokens": 40960, + "license": "apache-2.0", + "baseModel": "Qwen/Qwen3-0.6B", + "description": "The smallest model here, and the one most likely to run on a modest phone. Q8_0 keeps quantization loss minimal, so quality is limited by model size rather than by rounding. Strengths: fast, small, understands the Qwen3 tool-call format, and handles short instructions and simple single-step tool calls. Weaknesses: it is a 0.6B model - it loses track of long conversations, invents API details, and multi-step reasoning is unreliable. Treat it as a demo of the pipeline rather than a coding assistant.", + "behaviouralGatesVerified": false + }, + { + "id": "qwen3.5-0.8b-q4_k_m", + "name": "Qwen3.5 0.8B", + "quantization": "Q4_K_M", + "parameters": "0.8B", + "fileName": "Qwen3.5-0.8B-Q4_K_M.gguf", + "sizeBytes": 532517120, + "sha256": "bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517", + "url": "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/6ab461498e2023f6e3c1baea90a8f0fe38ab64d0/Qwen3.5-0.8B-Q4_K_M.gguf", + "minRamBytes": 3221225472, + "publisher": "unsloth - GGUF of Qwen/Qwen3.5-0.8B", + "contextTokens": 262144, + "license": "apache-2.0", + "baseModel": "Qwen/Qwen3.5-0.8B", + "description": "The newest and smallest capable model in this catalog, and the one to try first on a low-memory phone. Strengths: about half a gigabyte on disk, a very large published context window, and a current-generation post-trained Qwen with the tool-call format the family is known for. Weaknesses: at 0.8B parameters its reasoning and factual recall are shallow, and the advertised 256k context is theoretical on a phone - the KV cache for anything near it will not fit, so keep conversations short.", + "behaviouralGatesVerified": false + }, + { + "id": "llama-3-2-1b-instruct-q4_k_m", + "name": "Llama 3.2 1B Instruct", + "quantization": "Q4_K_M", + "parameters": "1B", + "fileName": "Llama-3.2-1B-Instruct-Q4_K_M.gguf", + "sizeBytes": 807694368, + "sha256": "3f5a22426976ab26cfe84dba63c1d08391717abb1af893e10f1b2968d862dcc1", + "url": "https://huggingface.co/unsloth/Llama-3.2-1B-Instruct-GGUF/resolve/b69aef112e9f895e6f98d7ae0949f72ff09aa401/Llama-3.2-1B-Instruct-Q4_K_M.gguf", + "minRamBytes": 3221225472, + "publisher": "unsloth - GGUF of meta-llama/Llama-3.2-1B-Instruct", + "contextTokens": 131072, + "license": "llama3.2", + "baseModel": "meta-llama/Llama-3.2-1B-Instruct", + "description": "Meta's smallest instruct model, widely used and well documented. Strengths: solid short-form instruction following for its size, a large published context, and a chat template every runtime understands. Weaknesses: the Llama 3.2 Community Licence carries use restrictions and the upstream Meta repo is access-gated, so this is not a fully-open model; tool use at 1B is unreliable. Listed at the ticket owner's request despite the licence gate.", + "behaviouralGatesVerified": false + }, + { + "id": "smollm2-360m-instruct-q8_0", + "name": "SmolLM2 360M Instruct", + "quantization": "Q8_0", + "parameters": "360M", + "fileName": "smollm2-360m-instruct-q8_0.gguf", + "sizeBytes": 386404992, + "sha256": "48ab3034d0dd401fbc721eb1df3217902fee7dab9078992d66431f09b7750201", + "url": "https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF/resolve/593b5a2e04c8f3e4ee880263f93e0bd2901ad47f/smollm2-360m-instruct-q8_0.gguf", + "minRamBytes": 2147483648, + "publisher": "Hugging Face TB - official GGUF release", + "contextTokens": 8192, + "license": "apache-2.0", + "baseModel": "HuggingFaceTB/SmolLM2-360M-Instruct", + "description": "Hugging Face's smallest instruct model, fully open with a public training recipe. Strengths: tiny, Apache-2.0 on both sides, instruction-tuned, and Q8_0 so quantization costs nothing. Weaknesses: an 8k context window - half the ticket's 16k floor - so long files or conversations will not fit, and at 360M parameters answers are short and often wrong. Listed at the ticket owner's request despite the context gate.", + "behaviouralGatesVerified": false + }, + { + "id": "h2o-danube3-500m-base-q4_k_m", + "name": "H2O Danube3 500M (base)", + "quantization": "Q4_K_M", + "parameters": "500M", + "fileName": "h2o-danube3-500m-base.Q4_K_M.gguf", + "sizeBytes": 317877536, + "sha256": "0fbe6da64edea0423f9a0c23c6e9849ea07efaff4caafb51ec012002248b1376", + "url": "https://huggingface.co/mradermacher/h2o-danube3-500m-base-GGUF/resolve/1803e3f9ecba1ab5deb2d9354f932a2eb349840e/h2o-danube3-500m-base.Q4_K_M.gguf", + "minRamBytes": 2147483648, + "publisher": "mradermacher - GGUF of h2oai/h2o-danube3-500m-base", + "contextTokens": 8192, + "license": "apache-2.0", + "baseModel": "h2oai/h2o-danube3-500m-base", + "description": "H2O's small pretrained model, Apache-2.0. A base model, NOT instruction-tuned, so it continues text instead of answering, and it has an 8k context window. Strengths: very small, permissively licensed, trained on a well-described corpus. Weaknesses: unusable as an assistant as shipped, no chat template, no tool use, and below the 16k context floor. Listed at the ticket owner's request; it meets neither the instruction-tuned nor the context gate.", + "behaviouralGatesVerified": false + } + ] +} diff --git a/get-ai-models/src/main/assets/docs/index.html b/get-ai-models/src/main/assets/docs/index.html new file mode 100644 index 00000000..ec0f3eb5 --- /dev/null +++ b/get-ai-models/src/main/assets/docs/index.html @@ -0,0 +1,225 @@ + + + + + + Get AI Models + + + + +

Get AI Models

+

Browse a bundled catalog of small GGUF language-model files and download them to +your device, checksum-verified. Each row shows its own licence - read it before use.

+ +
+ This plugin downloads models. It does not run them. Loading a .gguf and + generating text is the job of a separate AI plugin built around llama.cpp. Your device's memory is + never checked. The only thing recorded is which files have passed their checksum, so a verified + download is not forgotten when the IDE restarts - and so a file it downloaded can be deleted + again from the same row. +
+ +

Where to find it

+

Open a project, then open the editor's bottom drawer. Get AI Models sits beside the +built-in tabs (Build Output, App Logs, and so on). Long-press the tab, a row, or any of the +Download / Cancel / Verify / Delete controls for a quick explanation of each.

+ +

Reading a row

+

Each row is one file - a model at one quantization - so the same model can appear more +than once at different quantizations. The compact line under the name reads:

+

parameters · quantization · on-disk size

+

Tap the row to expand it. The expanded view adds:

+
    +
  • Strengths and weaknesses - written for this exact file, not for the model family. + Quantization is part of the judgement: a 4-bit build of a bigger model and an 8-bit build of a + smaller one behave differently.
  • +
  • Publisher - who published the GGUF. That is often a re-uploader who converted + someone else's weights, which is why the licence is checked on both.
  • +
  • Minimum RAM - a judgement call about what the file needs to be usable, shown for + you to weigh. It is informational only; the plugin never reads your device's memory and never + blocks a download because of it.
  • +
  • Licence, context window, and base model.
  • +
+ +

Downloading

+
    +
  1. Tap Download.
  2. +
  3. If there is no connection, nothing starts and the tab says so.
  4. +
  5. If the connection is metered, you are warned with the file size and can proceed or cancel. + Cancelling starts nothing.
  6. +
  7. Otherwise the file is handed to Android's download manager. Progress appears in the system + notification area and on the row itself: the Download button is replaced by a + Cancel control whose background fills as the file arrives, with the exact figures beneath + the model name.
  8. +
  9. When the transfer finishes, the file's SHA-256 is computed and compared with the catalog. + The button reads Verifying… while that happens; on a multi-gigabyte file it takes + a few seconds.
  10. +
  11. On a match the file is kept and the row reads Downloaded. On a mismatch the file is + deleted and you are offered a re-download.
  12. +
+ +

If the download seems to be stuck

+

Dismissing the system notification does not cancel the download - it only removes the +notification. The row keeps reporting on its own, which is why progress is drawn there too.

+

Check the line beneath the model name:

+
    +
  • Downloading 1.4 GB of 4.68 GB - bytes are moving. Large files over a slow link + genuinely take a long time.
  • +
  • Paused - waiting for Wi-Fi - the download began on Wi-Fi and you are now on mobile + data. It resumes by itself when Wi-Fi returns, and will not spend your data allowance in the + meantime. To fetch it over mobile data instead, cancel and start it again while on that + connection, and accept the metered warning.
  • +
  • Paused - no connection - waiting for any usable network.
  • +
  • Paused - retrying - a transient error; Android retries shortly.
  • +
  • Queued - waiting to start - accepted but not begun yet.
  • +
+

Tap the Cancel control to abandon a download; the partial file is discarded.

+ +

Where files land

+

Always /sdcard/Download, under the file name shown in the catalog. There is no +other destination and no setting to change it. If a file of that name already exists, Android's +download manager writes a suffixed copy (…-1.gguf) and the checksum is computed +against whichever file it actually wrote.

+

On some Android versions the download manager hands back a file without telling us its folder. The +checksum is still verified, and the file is still in your Downloads - but the row will say the location +is unknown, will not offer Delete file, and will forget the Downloaded badge when the IDE +restarts. Your Downloads app can still see the file.

+ +

What happens if you close the tab

+

The transfer and the checksum gate keep going - they belong to the plugin, not to the tab. +Reopening the tab shows the current state of each row. A download still in flight when the +plugin is reloaded finishes in the notification area but is no longer checksum-verified by +us.

+ +

What the plugin remembers

+

A file that passes the checksum is remembered, so Downloaded survives restarting the IDE. +Hashing several gigabytes takes real time, so the result is stored rather than recomputed: the entry +id, where the file was written, and its size at that moment.

+

A remembered badge is never trusted blindly. On start-up each record is re-checked with a single +cheap look at the disk:

+ + + + + + + + + + + + + + +
On diskThe row shows
Nothing at that pathDownload. The record is dropped - this is what you see after deleting the file in + a file manager.
Present, same size as when it was verifiedDownloaded.
Present, different sizeVerify. The old checksum no longer holds, so it is not claimed.
+

The size check catches a truncated or half-written file instantly and for free. It cannot spot a +file replaced by a different file of exactly the same length - for that, expand a verified row +and tap Verify file again to hash the bytes now.

+

If a re-verify fails, the file is left in place. That is different from a failed download, +which is deleted: a file already sitting on your device was not written by this download, so removing +it is your decision.

+ +

Deleting a file

+

An expanded row for a file you have downloaded also offers Delete file. It frees the space +and returns the row to Download, so nothing stops you fetching it again later.

+

You are asked to confirm first, and the confirmation names the file and its full path. There is no +undo and no trash.

+

What it can touch. Only the file this plugin downloaded, at the exact path it was written +to. A .gguf you put in /sdcard/Download yourself will never be offered for +deletion, even if it happens to share a catalogued file name.

+
    +
  • If a model is currently loaded by an AI plugin, the row clears but the disk space is + not reclaimed until that plugin releases the file. Unload the model first if you are deleting to + free space.
  • +
  • The system Downloads list may keep listing the file until Android next scans the folder. The + file is gone; only the listing is stale.
  • +
  • If the delete fails, the row is left as it was and says so - it will not pretend a file is + gone when it is not.
  • +
+

Everything remembered lives in the plugin's own storage and is cleared when the plugin is +uninstalled. Nothing about your device or your downloads leaves the device.

+ +

Why a download can fail

+ + + + + + + + + + + + + + + + + + + + + + +
What you seeWhat it means
No internet connection.No usable network. Nothing was started.
Metered connection warningYou are on cellular or a metered hotspot. Proceeding allows the transfer to use that + connection. A download that started on Wi-Fi will not silently continue over + cellular - it pauses until an unmetered network is back.
Checksum did not match - the file was deletedThe bytes on disk are not the bytes the catalog pins. Usually a truncated or resumed + transfer. Retrying is the right response.
Could not download: download failed (reason N)Android's download manager gave up. N is its own reason code - commonly + insufficient free space, or an HTTP error from the host.
Could not download: download was cancelledThe download was removed outside the IDE, typically from the system Downloads app.
+ +

How a model gets into the catalog

+

The catalog is a static JSON file inside the plugin. It is not fetched, updatable, or +user-editable: a new list ships as a new plugin version. Entries are admitted by checklist, not by +taste. Every one of these has to hold:

+
    +
  1. Instruction-tuned, not a base model.
  2. +
  3. A fully open licence with no restrictions - verified on the base model and on the + GGUF re-uploader's repository. Waived for some current entries, so check the licence shown + on the row itself.
  4. +
  5. The exact file loads and generates coherent output in our llama.cpp-based AI plugin.
  6. +
  7. It passes a fixed tool-calling evaluation at 80% or better.
  8. +
  9. A valid chat template, embedded in the GGUF or documented for it.
  10. +
  11. A single-file download from a reputable uploader, with the SHA-256 pinned to a specific + repository revision.
  12. +
  13. A context window of at least 16k tokens.
  14. +
+

Quantization is deliberately not restricted. Q4_0 and Q4_K_M +are common, but any quantization that clears the gates is eligible - the gates are about behaviour, +not about a preferred format.

+

Gates 3 and 4 are proven by a repeatable admission test that runs the candidate through the AI +plugin. That harness does not exist yet, so nothing in this version has been through it - every +expanded row says so. They are unproven, not failed: no listed file has been shown to misbehave, and +none has been confirmed to work either. Peak memory and tokens-per-second budgets are deliberately +not gates yet; they may be added later.

+ +

Privacy and network use

+

The plugin talks to exactly one kind of address: the HTTPS download URL stored in the catalog +for the row you tapped. It sends no telemetry, fetches no remote catalog, and makes no request +until you tap Download.

+ + + diff --git a/get-ai-models/src/main/assets/icon_day.png b/get-ai-models/src/main/assets/icon_day.png new file mode 100644 index 0000000000000000000000000000000000000000..3d2bdaa47603e7a1ecb457ed96fb0d84264461a4 GIT binary patch literal 6901 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 ll5T-H>g13s!8O$~thYmZA8>rV-)AwrjF20Mr0 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 = listOf( + TabItem( + id = TAB_ID, + title = "Get AI Models", + fragmentFactory = { ModelCatalogFragment() }, + order = 210, + tooltipTag = TOOLTIP_TAG_TAB + ) + ) + + // Must be exactly "plugin_", or the host's lookup misses and tooltips render "n/a". + override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID" + + override fun getTooltipEntries(): List = listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG_TAB, + summary = "Get AI Models
Download curated, small GGUF language-model files " + + "to your Downloads folder.", + detail = """ +

Get AI Models

+

Lists a bundled catalog of open-licence .gguf model files. One row + per file: a model at one quantization. Tap a row to read what it is good and bad + at; tap Download to fetch it.

+

What happens on download

+
    +
  1. Android's download manager fetches the file to + /sdcard/Download; progress appears in the system notification.
  2. +
  3. On a metered connection you are warned first and can cancel.
  4. +
  5. When the transfer finishes the file's SHA-256 is compared with the + catalog. A mismatch deletes the file and offers a re-download.
  6. +
+

What it does not do

+

It does not run models. Loading a downloaded .gguf is the job of a + separate AI plugin. The minimum-RAM figure is informational only - your device's + memory is not checked, and nothing here tracks which models you already have.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "Get AI Models guide", + uri = "index.html", + order = 0 + ) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_ROW, + summary = "One catalog row = one model file. Tap to expand for strengths, weaknesses, " + + "publisher, and minimum RAM.", + detail = """ +

Catalog rows

+

Each row is a single downloadable file - a model at one quantization - so the + same model can appear more than once. The compact line under the name reads + parameters · quantization · on-disk size.

+

Expanding a row adds:

+
    +
  • a strengths-and-weaknesses paragraph written for this exact file;
  • +
  • the publisher of the GGUF, which is often a re-uploader rather than + the model's author;
  • +
  • the minimum RAM we would want to see before trying to run it - + informational only, never enforced;
  • +
  • licence and context-window size.
  • +
+

Every file is pinned to a specific upload revision, so the catalog's checksum + stays valid. Check the licence line before using a model: most are Apache-2.0, + but the catalog also lists entries under restricted licences at the maintainer's + request.

+ """.trimIndent() + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_DOWNLOAD, + summary = "Downloads this file to /sdcard/Download, then verifies its SHA-256.", + detail = """ +

Download

+

Hands the file to Android's download manager, which shows progress in the system + notification area. The button is then replaced by a Cancel control whose + background fills as the file arrives, so the row still reports progress if you dismiss + that notification. It becomes Verifying and finally Downloaded, which is + remembered across restarts for as long as the file stays put.

+
    +
  • No connection - nothing starts, and you get a message saying so.
  • +
  • Metered connection - you are warned with the download size and can + proceed or cancel. A download started on Wi-Fi will not silently continue over + cellular data; it pauses instead, and the row says so.
  • +
  • Checksum mismatch - the file is deleted and the button becomes + Retry. A partial or tampered file is never kept.
  • +
+

Files always land in /sdcard/Download; there is no other + destination and no in-app progress bar.

+ """.trimIndent() + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CANCEL, + summary = "Shows how far the download has got, and stops it. The fill behind the label " + + "is the progress.", + detail = """ +

While a download is running

+

The Download button is replaced by this control. Its background fills up as bytes + arrive, the label shows the percentage, and the line beneath the model name gives the + exact figures - for example 1.4 GB of 4.68 GB.

+

Tapping it cancels the download and discards the partial file. Nothing is + left half-written in /sdcard/Download.

+

When it says Paused

+
    +
  • Waiting for Wi-Fi - the download started on Wi-Fi and the device is now + on mobile data. It resumes by itself when Wi-Fi returns; it will not spend your + data allowance behind your back. Cancel and start again if you would rather + download it over mobile data.
  • +
  • No connection - waiting for any usable network.
  • +
  • Retrying - a transient error; Android will try again shortly.
  • +
+

This row is deliberately readable on its own, because the system download + notification can be dismissed - and once it is gone, this is the only place left that + can tell you whether anything is still happening.

+ """.trimIndent() + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_DELETE, + summary = "Permanently deletes this downloaded file from your device. Asks first.", + detail = """ +

Delete file

+

Removes the .gguf this plugin downloaded for that row, freeing the + space. You are asked to confirm first, and the confirmation names the exact file and + its full path so you can see what is about to go. There is no undo and no trash - but + the row returns to Download, so you can always fetch it again.

+

What it will and will not touch

+

Delete only appears on a row this plugin has downloaded and recorded, and it only + ever removes that file, at the exact path it was written to. A file you put in + /sdcard/Download yourself is never a candidate, even if it happens to + share the catalogued name.

+

Two things worth knowing

+
    +
  • If a model is currently loaded by an AI plugin, the row clears but the + disk space is not reclaimed until that plugin releases the file. Unload the model + first if you are deleting to free space.
  • +
  • The system Downloads list may keep showing the file until Android next scans + the folder. It is gone; only the listing is stale.
  • +
+

On a row marked as changed, the file on disk is no longer the one that was + verified - the confirmation says so, because in that case it may be something you put + there deliberately.

+ """.trimIndent() + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_VERIFY, + summary = "Re-computes this file's SHA-256 and compares it with the catalog. Nothing is " + + "deleted.", + detail = """ +

Verify a file again

+

A verified download is remembered across restarts, and on start-up each remembered + file is re-checked cheaply: is it still there, and is it still the size that was + hashed? That catches a deleted or truncated file instantly and for free.

+

What it cannot catch is a file swapped for a different file of exactly the + same length. This action closes that gap by hashing the bytes again. It takes a few + seconds to about half a minute depending on the file.

+
    +
  • Match - the row stays verified and the record is refreshed.
  • +
  • Mismatch - the row reports the failure and offers a fresh download. + The file on disk is left in place: it was not written by this download, so + removing it is your call, not the plugin's.
  • +
+

A row whose file has changed size shows Verify in place of Download until + you re-check it, rather than claiming a checksum that no longer holds.

+ """.trimIndent() + ) + ) + + /** Tier 3 offline help: everything under src/main/assets/docs/, entry point index.html. */ + override fun getTier3DocsAssetPath(): String = "docs" + + override fun onDocumentationInstall(): Boolean { + context.logger.info("Installing GetAiModelsPlugin documentation") + return true + } + + override fun onDocumentationUninstall() { + context.logger.info("Removing GetAiModelsPlugin documentation") + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/GetAiModelsRuntime.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/GetAiModelsRuntime.kt new file mode 100644 index 00000000..e3f39cc2 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/GetAiModelsRuntime.kt @@ -0,0 +1,26 @@ +package org.appdevforall.getaimodels + +import org.appdevforall.getaimodels.download.ModelDownloader + +/** + * Hands the plugin-scoped [ModelDownloader] to fragments, which the IDE constructs itself and so + * cannot take constructor arguments. The only mutable state outside a fragment: it holds the + * application context, never an Activity or View, and [detach] clears it on plugin teardown. + */ +object GetAiModelsRuntime { + + @Volatile + private var downloader: ModelDownloader? = null + + fun attach(downloader: ModelDownloader) { + this.downloader = downloader + } + + fun detach() { + downloader?.dispose() + downloader = null + } + + /** Null when the plugin is not active; the UI degrades to a "not available" message. */ + fun downloader(): ModelDownloader? = downloader +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/ByteSize.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/ByteSize.kt new file mode 100644 index 00000000..1f04bb8a --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/ByteSize.kt @@ -0,0 +1,20 @@ +package org.appdevforall.getaimodels.catalog + +import java.util.Locale + +/** Byte-count formatting shared by the row spec line and the expanded detail. */ +object ByteSize { + + private const val GIB = 1024.0 * 1024.0 * 1024.0 + private const val MIB = 1024.0 * 1024.0 + + /** e.g. 2497276320 -> "2.33 GB"; values under 1 GiB render as whole MB. */ + fun format(bytes: Long): String = when { + bytes >= GIB -> String.format(Locale.US, "%.2f GB", bytes / GIB) + else -> String.format(Locale.US, "%.0f MB", bytes / MIB) + } + + /** Whole-GB form used for the informational minimum-RAM line, e.g. "6 GB". */ + fun formatWholeGb(bytes: Long): String = + String.format(Locale.US, "%.0f GB", bytes / GIB) +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogEntry.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogEntry.kt new file mode 100644 index 00000000..c81f03a7 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogEntry.kt @@ -0,0 +1,39 @@ +package org.appdevforall.getaimodels.catalog + +/** + * One downloadable file: a (model, quantization) pair, not a model, so two quantizations are two + * entries. Every field comes from the catalog asset - nothing is computed on device or fetched at + * runtime. See docs/CURATION.md for the admission gates an entry has to clear. + */ +data class CatalogEntry( + /** Stable id, unique within the catalog. Used as the download-state key. */ + val id: String, + /** Human-readable model name, e.g. "Qwen3 4B". */ + val name: String, + /** GGUF quantization of this specific file, e.g. "Q4_K_M". Any quantization is allowed. */ + val quantization: String, + /** Parameter count as published, e.g. "4B" - a label, not a number. */ + val parameters: String, + /** Destination file name under /sdcard/Download. */ + val fileName: String, + /** Exact on-disk size of the .gguf in bytes, as published by the host repository. */ + val sizeBytes: Long, + /** Lower-case hex SHA-256 of the file. A hard gate: a mismatch deletes the download. */ + val sha256: String, + /** Direct single-file HTTPS download URL, pinned to a specific repository revision. */ + val url: String, + /** Informational minimum device RAM. Display-only - the device's RAM is never checked. */ + val minRamBytes: Long, + /** Who publishes this GGUF file (the re-uploader, not only the base model author). */ + val publisher: String, + /** Context window in tokens, as published by the base model. */ + val contextTokens: Int, + /** SPDX-ish license id of the base model and this GGUF re-upload, e.g. "apache-2.0". */ + val license: String, + /** Repository id of the base model the GGUF was converted from. */ + val baseModel: String, + /** Strengths-and-weaknesses paragraph shown when the row is expanded. */ + val description: String, + /** True once gates 3 and 4 (loads in the AI plugin, tool-use eval) are proven by the harness. */ + val behaviouralGatesVerified: Boolean +) diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoader.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoader.kt new file mode 100644 index 00000000..b7f19e76 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoader.kt @@ -0,0 +1,77 @@ +package org.appdevforall.getaimodels.catalog + +import android.content.res.AssetManager +import org.json.JSONObject + +/** + * Reads the static catalog bundled as an asset; there is no remote one. A parse failure is therefore + * a packaging bug rather than a runtime condition, so [parse] rejects a malformed catalog loudly + * instead of silently dropping rows. + */ +object CatalogLoader { + + const val ASSET_PATH = "catalog/models.json" + + private val SHA256_HEX = Regex("^[0-9a-f]{64}$") + + fun load(assets: AssetManager): List = + assets.open(ASSET_PATH).use { parse(it.reader().readText()) } + + /** Parses catalog JSON. Throws [IllegalArgumentException] if any entry is incomplete. */ + fun parse(json: String): List { + val root = JSONObject(json) + val models = root.getJSONArray("models") + require(models.length() > 0) { "catalog contains no models" } + + val entries = (0 until models.length()).map { index -> + val o = models.getJSONObject(index) + val id = o.getString("id") + val entry = CatalogEntry( + id = id, + name = o.getString("name"), + quantization = o.getString("quantization"), + parameters = o.getString("parameters"), + fileName = o.getString("fileName"), + sizeBytes = o.getLong("sizeBytes"), + sha256 = o.getString("sha256").lowercase(), + url = o.getString("url"), + minRamBytes = o.getLong("minRamBytes"), + publisher = o.getString("publisher"), + contextTokens = o.getInt("contextTokens"), + license = o.getString("license"), + baseModel = o.getString("baseModel"), + description = o.getString("description"), + // getBoolean, not optBoolean: a new entry must state its gate status explicitly. + behaviouralGatesVerified = o.getBoolean("behaviouralGatesVerified") + ) + validate(entry, index) + entry + } + + val duplicates = entries.groupBy { it.id }.filterValues { it.size > 1 }.keys + require(duplicates.isEmpty()) { "duplicate catalog ids: $duplicates" } + return entries + } + + private fun validate(entry: CatalogEntry, index: Int) { + fun bad(reason: String): Nothing = + throw IllegalArgumentException("catalog entry #$index (${entry.id}): $reason") + + if (entry.id.isBlank()) bad("blank id") + if (entry.name.isBlank()) bad("blank name") + if (entry.quantization.isBlank()) bad("blank quantization") + if (!entry.fileName.endsWith(".gguf")) bad("fileName is not a .gguf: ${entry.fileName}") + // A path separator in fileName would let the catalog write outside /sdcard/Download. + if (entry.fileName.contains('/') || entry.fileName.contains('\\')) { + bad("fileName must not contain a path separator: ${entry.fileName}") + } + if (entry.sizeBytes <= 0) bad("sizeBytes must be positive") + if (!SHA256_HEX.matches(entry.sha256)) bad("sha256 is not 64 lower-case hex chars") + // HTTPS only: the checksum protects integrity, but the URL still must not be cleartext. + if (!entry.url.startsWith("https://")) bad("url is not https: ${entry.url}") + if (entry.minRamBytes <= 0) bad("minRamBytes must be positive") + if (entry.publisher.isBlank()) bad("blank publisher") + if (entry.description.isBlank()) bad("blank description") + if (entry.contextTokens <= 0) bad("contextTokens must be positive") + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadManagerClient.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadManagerClient.kt new file mode 100644 index 00000000..891e9903 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadManagerClient.kt @@ -0,0 +1,188 @@ +package org.appdevforall.getaimodels.download + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import com.itsaky.androidide.plugins.PluginLogger +import org.appdevforall.getaimodels.catalog.CatalogEntry +import java.io.File + +/** One row of DownloadManager's cursor, copied out so no Cursor escapes this file. */ +data class DownloadSnapshot( + val id: Long, + val status: Int, + val reason: Int, + val bytesSoFar: Long, + val totalBytes: Long, + val localUri: String? +) + +/** Where a finished download's bytes are, once DownloadManager's columns have been interpreted. */ +sealed interface DownloadedLocation { + + /** A real filesystem path: hashable, recordable across restarts, and deletable. */ + data class OnDisk(val path: String) : DownloadedLocation + + /** + * Reachable only through the downloads provider. Hashable through a ContentResolver, but with no + * path to persist or delete, so the row's badge will not survive a restart. + */ + data class ViaProvider(val uri: String) : DownloadedLocation +} + +/** + * Works out where a finished download landed, from `COLUMN_LOCAL_URI` and `COLUMN_LOCAL_FILENAME`. + * Uses [java.net.URI] rather than `android.net.Uri` so the scheme handling is pure and + * unit-testable off-device. + */ +object DownloadedFileResolver { + + /** + * Resolution runs in three tiers, because which columns DownloadManager populates varies with + * the platform version: + * + * 1. a `file://` [localUri] - the usual case for a destination in the public Downloads dir; + * 2. [localFileName], which still carries the absolute path when the URI came back as + * `content://`; + * 3. a `content://` [localUri] on its own: hashable, but pathless. + * + * A `content://` URI must never reach [File]: its path (`/all_downloads/42`) is not a filesystem + * path, and treating it as one reported every finished download as missing. + */ + fun resolve(localUri: String?, localFileName: String?): DownloadedLocation? { + val uri = localUri?.takeIf { it.isNotBlank() } + ?.let { runCatching { java.net.URI(it) }.getOrNull() } + + // getPath() percent-decodes, so a name with spaces resolves to the real file. + if (uri != null && uri.scheme.equals("file", ignoreCase = true)) { + uri.path?.takeIf { it.isNotBlank() }?.let { return DownloadedLocation.OnDisk(it) } + } + + localFileName?.takeIf { it.isNotBlank() } + ?.let { return DownloadedLocation.OnDisk(it) } + + if (uri != null && uri.scheme.equals("content", ignoreCase = true)) { + return DownloadedLocation.ViaProvider(uri.toString()) + } + return null + } +} + +/** + * Everything that talks to the platform DownloadManager: request building, the cursor read, opening a + * finished download's bytes, and removal. Keeps Binder, Cursor and ContentResolver handling out of + * [ModelDownloader], which orchestrates state. + */ +class DownloadManagerClient( + private val appContext: Context, + private val logger: PluginLogger +) { + + private val downloadManager = + appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + + /** + * Queues [entry] and returns its download id; throws whatever DownloadManager throws. + * + * Touches disk (the destination is stat-ed) and inserts into the downloads provider over Binder, + * so callers must be off the main thread. + */ + fun enqueue(entry: CatalogEntry, allowOverMetered: Boolean): Long { + val request = DownloadManager.Request(Uri.parse(entry.url)) + .setTitle(entry.name) + .setDescription("${entry.quantization} - ${entry.fileName}") + .setMimeType("application/octet-stream") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, entry.fileName) + .setAllowedOverMetered(allowOverMetered) + .setAllowedOverRoaming(false) + return downloadManager.enqueue(request) + } + + /** Cancels a download and deletes its file, partial or complete. */ + fun remove(downloadId: Long) { + downloadManager.remove(downloadId) + } + + /** + * What the gate should hash for a finished [row], or null when the download cannot be located at + * all. A pathless target still verifies; it just cannot be recorded or deleted afterwards. + */ + fun verifyTarget(row: DownloadSnapshot, entry: CatalogEntry): VerifyTarget? { + // Tier 2 costs a second query and touches a column that can throw, so it is only reached + // when the URI alone did not already yield a path. + val location = DownloadedFileResolver.resolve(row.localUri, null) + ?.takeIf { it is DownloadedLocation.OnDisk } + ?: DownloadedFileResolver.resolve(row.localUri, legacyLocalFileName(row.id)) + + return when (location) { + is DownloadedLocation.OnDisk -> VerifyTarget.ofFile(File(location.path)) + + is DownloadedLocation.ViaProvider -> VerifyTarget( + displayName = entry.fileName, + path = null + ) { + val uri = Uri.parse(location.uri) + appContext.contentResolver.openInputStream(uri) + ?: error("the downloads provider returned no stream for $uri") + } + + null -> null + } + } + + /** + * `COLUMN_LOCAL_FILENAME` for one download, or null when it cannot be read. + * + * Deprecated since API 24, and on Android 10+ reading it throws once the provider actually has a + * path to report. It is therefore read here - once, for a download that has already finished, and + * inside a catch - never in [snapshot]. Reading it per poll froze every row: the first poll saw the + * column still NULL and succeeded, and every poll after the path appeared threw instead of + * reporting progress. + */ + @Suppress("DEPRECATION") + private fun legacyLocalFileName(downloadId: Long): String? = try { + downloadManager.query(DownloadManager.Query().setFilterById(downloadId))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.stringOrNull(DownloadManager.COLUMN_LOCAL_FILENAME) + } else { + null + } + } + } catch (t: Throwable) { + logger.info("COLUMN_LOCAL_FILENAME is unreadable on this platform: ${t.message}") + null + } + + /** Current state of [ids]; ids DownloadManager no longer knows about are simply absent. */ + fun snapshot(ids: LongArray): List { + if (ids.isEmpty()) return emptyList() + val rows = mutableListOf() + downloadManager.query(DownloadManager.Query().setFilterById(*ids))?.use { cursor -> + while (cursor.moveToNext()) { + rows += DownloadSnapshot( + id = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)), + status = cursor.getInt( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS) + ), + reason = cursor.longOrZero(DownloadManager.COLUMN_REASON).toInt(), + bytesSoFar = cursor.longOrZero(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR), + totalBytes = cursor.longOrZero(DownloadManager.COLUMN_TOTAL_SIZE_BYTES), + localUri = cursor.stringOrNull(DownloadManager.COLUMN_LOCAL_URI) + ) + } + } + return rows + } + + private fun android.database.Cursor.longOrZero(column: String): Long { + val index = getColumnIndex(column) + return if (index >= 0 && !isNull(index)) getLong(index) else 0L + } + + private fun android.database.Cursor.stringOrNull(column: String): String? { + val index = getColumnIndex(column) + return if (index >= 0 && !isNull(index)) getString(index) else null + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadState.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadState.kt new file mode 100644 index 00000000..93c56390 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/DownloadState.kt @@ -0,0 +1,101 @@ +package org.appdevforall.getaimodels.download + +/** + * Per-entry row state. [Verified] survives a plugin reload because [VerifiedModelStore] persists it + * and [ModelDownloader.restore] revalidates it against the disk - a badge outliving the file it + * describes would be worse than no badge. + */ +sealed class DownloadState { + + /** Never started, cancelled, or reset after a failure the user has been told about. */ + object Idle : DownloadState() + + /** + * Handed to DownloadManager, and self-sufficient enough to render the row without the system + * notification the user can dismiss. [totalBytes] falls back to the catalogued size when + * DownloadManager reports an unknown total, so there is always a denominator. + */ + data class Downloading( + val bytesSoFar: Long, + val totalBytes: Long, + val phase: Phase + ) : DownloadState() { + + /** 0f..1f, clamped. Drives the fill drawn behind the Cancel button. */ + val fraction: Float + get() = if (totalBytes <= 0L) 0f + else (bytesSoFar.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f) + + val isPaused: Boolean get() = phase != Phase.PENDING && phase != Phase.RUNNING + } + + /** Download finished, SHA-256 is being computed. */ + object Verifying : DownloadState() + + /** + * Checksum matched and the file was kept. [onDisk] is false in the one case where DownloadManager + * reports the finished download only through the downloads provider: the bytes were hashed, but + * with no filesystem path there is nothing to record for the next restart and nothing this plugin + * can delete, so [path] is only a name to show and the row offers neither action. + */ + data class Verified(val path: String, val onDisk: Boolean = true) : DownloadState() + + /** + * Still at [path] but no longer the size we hashed, so the verification no longer holds. The file + * is left alone and re-verifying is offered instead. + */ + data class Changed(val path: String) : DownloadState() + + /** + * Terminal failure. [checksumMismatch] distinguishes the "downloaded but corrupt, file + * deleted" case (which offers a re-download) from a transport failure. + */ + data class Failed(val message: String, val checksumMismatch: Boolean) : DownloadState() + + val isBusy: Boolean get() = this is Downloading || this is Verifying +} + +/** + * Whether a transfer is moving bytes, and if not, why. Mapped from DownloadManager's status and + * reason columns; these once all rendered as "Downloading...", making an indefinitely paused + * download indistinguishable from one making progress. + */ +enum class Phase { + /** Queued, not started yet. */ + PENDING, + + /** Actively transferring. */ + RUNNING, + + /** Began on an unmetered connection, now on a metered one; resumes when Wi-Fi returns. */ + PAUSED_WAITING_FOR_WIFI, + + /** Paused because there is no usable connection at all. */ + PAUSED_WAITING_FOR_NETWORK, + + /** Paused between automatic retries after a transient error. */ + PAUSED_WAITING_TO_RETRY, + + /** Paused for a reason DownloadManager did not classify. */ + PAUSED_UNKNOWN +} + +/** One-shot outcome, surfaced to the UI as a dialog or snackbar rather than as row state. */ +sealed class DownloadEvent { + data class Verified(val entryId: String, val modelName: String) : DownloadEvent() + + /** A *download* failed the checksum: the file was deleted and a re-download is offered. */ + data class ChecksumFailed(val entryId: String, val modelName: String) : DownloadEvent() + + /** Re-verification of a file already on disk failed; unlike [ChecksumFailed], nothing is deleted. */ + data class VerifyFailed(val entryId: String, val modelName: String) : DownloadEvent() + + data class Cancelled(val entryId: String, val modelName: String) : DownloadEvent() + + data class Deleted(val entryId: String, val modelName: String) : DownloadEvent() + + data class DeleteFailed(val entryId: String, val modelName: String) : DownloadEvent() + + data class TransportFailed(val entryId: String, val modelName: String, val reason: String) : + DownloadEvent() +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelDownloader.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelDownloader.kt new file mode 100644 index 00000000..6f84b974 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelDownloader.kt @@ -0,0 +1,416 @@ +package org.appdevforall.getaimodels.download + +import android.app.DownloadManager +import android.content.Context +import com.itsaky.androidide.plugins.PluginLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.appdevforall.getaimodels.catalog.CatalogEntry +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks which DownloadManager download belongs to which catalog entry, polls for completion, and + * publishes the row state and one-shot events the UI renders. Hashing, the verified-file record and + * deletion of a recorded file all belong to [ModelFileGate]; this class owns the transfer. + * + * Plugin-scoped so closing the tab abandons neither the transfer nor the checksum. Completion is + * polled rather than broadcast-received; [dispose] cancels everything. + */ +class ModelDownloader( + appContext: Context, + private val logger: PluginLogger, + private val gate: ModelFileGate +) { + + private companion object { + const val POLL_INTERVAL_MS = 1_500L + + /** Consecutive poll failures after which the rows stop claiming to know what is happening. */ + const val MAX_POLL_FAILURES = 5 + } + + private val downloads = DownloadManagerClient(appContext, logger) + + /** + * The handler is a backstop, not the error path: every launch below catches its own failures. + * Without it an unforeseen throw would reach Android's default handler and take the whole IDE + * down, which is never an acceptable outcome for a failed download. + */ + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, t -> + logger.error("Unhandled failure in the download scope", t) + } + ) + + private val _states = MutableStateFlow>(emptyMap()) + val states: StateFlow> = _states.asStateFlow() + + // extraBufferCapacity so a completion landing while the tab is closed cannot suspend the emitter. + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + /** DownloadManager id -> the catalog entry it was started for. */ + private val tracked = ConcurrentHashMap() + + /** Entry ids cancelled before their enqueue returned a download id. */ + private val cancelRequested: MutableSet = ConcurrentHashMap.newKeySet() + + private var pollJob: Job? = null + + fun stateOf(entryId: String): DownloadState = _states.value[entryId] ?: DownloadState.Idle + + /** + * Rebuilds row state from the persisted records, one stat each: gone -> record dropped and Idle, + * size differs -> [DownloadState.Changed], size matches -> [DownloadState.Verified]. Safe from the + * main thread. A same-length swap needs [verifyExisting], which this cannot detect. + */ + fun restore() { + scope.launch { + val records = try { + gate.records() + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.warn("Could not read verified-model records", t) + emptyMap() + } + if (records.isEmpty()) return@launch + + val restored = mutableMapOf() + records.forEach { (entryId, record) -> + when (DiskCheck.status(record.path, record.sizeBytes)) { + DiskStatus.ABSENT -> { + logger.info("Verified file for $entryId is gone; dropping the record") + gate.forget(entryId) + } + + DiskStatus.SIZE_MISMATCH -> { + logger.warn("Verified file for $entryId changed size; needs re-verification") + restored[entryId] = DownloadState.Changed(record.path) + } + + DiskStatus.MATCHES -> restored[entryId] = DownloadState.Verified(record.path) + } + } + + // One atomic merge: an entry the user started while this read the disk keeps its state. + _states.update { current -> + current + restored.filterKeys { it !in current } + } + } + } + + /** + * Hands [entry] to DownloadManager; callers own the network checks. Passing + * [allowOverMetered] false (the unmetered case) makes DownloadManager pause rather than spend + * mobile data if the device later drops to cellular. + */ + fun enqueue(entry: CatalogEntry, allowOverMetered: Boolean) { + if (stateOf(entry.id).isBusy) return + // Published on the calling thread (no disk) so the isBusy guard above rejects a second tap. + setState(entry.id, DownloadState.Downloading(0L, entry.sizeBytes, Phase.PENDING)) + scope.launch { enqueueBlocking(entry, allowOverMetered) } + } + + /** + * The disk-touching half of the handoff, hence off the main thread: the destination is stat-ed and + * the enqueue inserts into the downloads provider. StrictMode propagates across that Binder call, + * so running this on the main thread also reported the provider's own SQLite violations as ours. + */ + private fun enqueueBlocking(entry: CatalogEntry, allowOverMetered: Boolean) { + val id = try { + downloads.enqueue(entry, allowOverMetered) + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Failed to enqueue ${entry.fileName}", t) + cancelRequested.remove(entry.id) + setState(entry.id, DownloadState.Failed(t.message ?: "could not start", false)) + _events.tryEmit( + DownloadEvent.TransportFailed(entry.id, entry.name, t.message ?: "could not start") + ) + return + } + + tracked[id] = entry + logger.info("Queued ${entry.fileName} as download $id") + + // Cancel tapped before this enqueue landed: [cancel] had no id yet and left a note. + if (cancelRequested.remove(entry.id)) { + remove(id, entry) + return + } + startPolling() + } + + private fun startPolling() { + if (pollJob?.isActive == true) return + pollJob = scope.launch { + var failures = 0 + while (tracked.isNotEmpty()) { + delay(POLL_INTERVAL_MS) + try { + pollOnce() + failures = 0 + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + failures++ + logger.warn("Download poll failed ($failures)", t) + // A poll that keeps throwing used to leave every row frozen mid-progress for as + // long as the IDE ran, with nothing above a warning to say so. The transfer is + // DownloadManager's and continues in the notification; what is lost is our view + // of it, and the row now says that instead of showing a number that never moves. + if (failures >= MAX_POLL_FAILURES) { + logger.error("Abandoning download polling after $failures failures", t) + tracked.keys.toList().forEach { id -> + tracked.remove(id)?.let { + fail(it, "lost track of this download - check your Downloads") + } + } + } + } + } + } + } + + private fun pollOnce() { + val ids = tracked.keys.toLongArray() + if (ids.isEmpty()) return + + val rows = downloads.snapshot(ids) + rows.forEach { row -> + val entry = tracked[row.id] ?: return@forEach + when (row.status) { + DownloadManager.STATUS_SUCCESSFUL -> { + tracked.remove(row.id) + onDownloadFinished(entry, row) + } + + DownloadManager.STATUS_FAILED -> { + tracked.remove(row.id) + fail(entry, "download failed (reason ${row.reason})") + } + + // Pending/running/paused reported distinctly: the notification is dismissible. + else -> setState(entry.id, progressOf(row, entry)) + } + } + + // An id DownloadManager no longer knows about was cleared from the Downloads UI. + (tracked.keys - rows.map { it.id }.toSet()).forEach { id -> + tracked.remove(id)?.let { fail(it, "download was cancelled") } + } + } + + /** + * Starts verification of a finished download, or fails the row when the bytes cannot be located. + * Nothing is guessed from the catalogued name: hashing the wrong file would delete an unrelated + * same-named file on mismatch. + */ + private fun onDownloadFinished(entry: CatalogEntry, row: DownloadSnapshot) { + val target = downloads.verifyTarget(row, entry) + if (target == null) { + logger.error("Could not locate download ${row.id} from uri '${row.localUri}'") + fail(entry, "could not locate the downloaded file") + return + } + // Hashed off the poll loop so a second download's completion is still noticed promptly. + scope.launch { runGate(entry, target, failedDownloadId = row.id) } + } + + /** Maps a snapshot row onto the in-flight state the row renders. */ + private fun progressOf(row: DownloadSnapshot, entry: CatalogEntry): DownloadState.Downloading { + // -1 means the server sent no length; the catalogued size is the one we verify against. + val total = if (row.totalBytes > 0L) row.totalBytes else entry.sizeBytes + val phase = when (row.status) { + DownloadManager.STATUS_PENDING -> Phase.PENDING + DownloadManager.STATUS_PAUSED -> when (row.reason) { + DownloadManager.PAUSED_QUEUED_FOR_WIFI -> Phase.PAUSED_WAITING_FOR_WIFI + DownloadManager.PAUSED_WAITING_FOR_NETWORK -> Phase.PAUSED_WAITING_FOR_NETWORK + DownloadManager.PAUSED_WAITING_TO_RETRY -> Phase.PAUSED_WAITING_TO_RETRY + else -> Phase.PAUSED_UNKNOWN + } + + else -> Phase.RUNNING + } + return DownloadState.Downloading(row.bytesSoFar, total, phase) + } + + /** + * Abandons an in-flight download: `DownloadManager.remove` cancels it and deletes the partial + * file. The only way to stop a download from the IDE, and the only one at all once the system + * notification has been dismissed. + */ + fun cancel(entryId: String) { + val tracking = tracked.entries.firstOrNull { it.value.id == entryId } + if (tracking == null) { + // No id yet (enqueue still on IO); note it rather than stranding the row in Downloading. + if (stateOf(entryId) is DownloadState.Downloading) cancelRequested.add(entryId) + return + } + val downloadId = tracking.key + val entry = tracking.value + tracked.remove(downloadId) + scope.launch { remove(downloadId, entry) } + } + + /** Drops a download from DownloadManager - this also deletes its partial file. */ + private fun remove(downloadId: Long, entry: CatalogEntry) { + tracked.remove(downloadId) + try { + downloads.remove(downloadId) + } catch (t: Throwable) { + logger.warn("Could not remove download $downloadId", t) + } + logger.info("Cancelled ${entry.fileName}") + setState(entry.id, DownloadState.Idle) + _events.tryEmit(DownloadEvent.Cancelled(entry.id, entry.name)) + } + + /** + * Re-runs the SHA-256 gate on a file already on disk - the escape hatch for the same-length swap + * [restore]'s size check cannot see. Never deletes on mismatch: that file was not written by this + * download, so removing it is the user's call. + */ + fun verifyExisting(entry: CatalogEntry, path: String) { + if (stateOf(entry.id).isBusy) return + setState(entry.id, DownloadState.Verifying) + scope.launch { + runGate(entry, VerifyTarget.ofFile(File(path)), failedDownloadId = null) + } + } + + /** + * Deletes a file this plugin downloaded. [path] must come from a persisted record (a + * [DownloadState.Verified] or [DownloadState.Changed] row), never from the catalogued file name - + * a same-named file the user put there is not ours. + */ + fun deleteDownloadedFile(entry: CatalogEntry, path: String) { + // Refused while hashing: deleting under Sha256.of() surfaces as a confusing read error. + if (stateOf(entry.id).isBusy) return + scope.launch { + val deleted = try { + gate.deleteRecorded(entry.id, path) + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + // A SecurityException from an unwritable path used to reach the default handler. + logger.error("Could not delete $path", t) + false + } + if (deleted) { + setState(entry.id, DownloadState.Idle) + _events.tryEmit(DownloadEvent.Deleted(entry.id, entry.name)) + } else { + _events.tryEmit(DownloadEvent.DeleteFailed(entry.id, entry.name)) + } + } + } + + /** + * Runs the gate and turns its result into row state and one event. [failedDownloadId] is the + * download to remove if the checksum fails - non-null only for a fresh download, where a corrupt + * file is deleted rather than kept. Re-verification of an existing file passes null: it never + * deletes. + */ + private suspend fun runGate( + entry: CatalogEntry, + target: VerifyTarget, + failedDownloadId: Long? + ) { + setState(entry.id, DownloadState.Verifying) + val result = try { + gate.verify(entry, target) + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Verification of ${target.displayName} failed outright", t) + fail(entry, "could not verify the file") + return + } + + when (result) { + is VerifyResult.Matched -> { + setState(entry.id, DownloadState.Verified(result.displayPath, result.recorded)) + if (!result.recorded) { + // Hashed through the downloads provider: correct, but nothing to record. + logger.warn( + "${entry.fileName} verified without a filesystem path; the badge will " + + "not survive a restart and the file cannot be deleted from here" + ) + } + _events.tryEmit(DownloadEvent.Verified(entry.id, entry.name)) + } + + VerifyResult.Missing -> fail(entry, "the file is missing") + + is VerifyResult.Unreadable -> fail(entry, result.reason) + + VerifyResult.Mismatched -> onChecksumMismatch(entry, target, failedDownloadId) + } + } + + /** + * Hard gate on a download: a file that fails the checksum is deleted, never kept. Deletion goes + * through DownloadManager, which removes the row and the file whether or not we hold a path - + * with a direct delete as a fallback for a path it declines to clean up. + */ + private fun onChecksumMismatch( + entry: CatalogEntry, + target: VerifyTarget, + failedDownloadId: Long? + ) { + if (failedDownloadId == null) { + setState(entry.id, DownloadState.Failed("checksum did not match", false)) + _events.tryEmit(DownloadEvent.VerifyFailed(entry.id, entry.name)) + return + } + + try { + downloads.remove(failedDownloadId) + } catch (t: Throwable) { + logger.warn("Could not remove the failed download $failedDownloadId", t) + } + val leftover = target.path?.let(::File) + if (leftover != null && leftover.exists() && !leftover.delete()) { + logger.warn("Could not delete corrupt download ${leftover.absolutePath}") + } + setState(entry.id, DownloadState.Failed("checksum did not match", true)) + _events.tryEmit(DownloadEvent.ChecksumFailed(entry.id, entry.name)) + } + + private fun fail(entry: CatalogEntry, message: String) { + logger.warn("${entry.fileName}: $message") + setState(entry.id, DownloadState.Failed(message, false)) + _events.tryEmit(DownloadEvent.TransportFailed(entry.id, entry.name, message)) + } + + private fun setState(entryId: String, state: DownloadState) { + // update() rather than a read-modify-write on .value: verifications run concurrently. + _states.update { it + (entryId to state) } + } + + fun dispose() { + scope.cancel() + pollJob = null + tracked.clear() + cancelRequested.clear() + _states.value = emptyMap() + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelFileGate.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelFileGate.kt new file mode 100644 index 00000000..88fc6c63 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/ModelFileGate.kt @@ -0,0 +1,129 @@ +package org.appdevforall.getaimodels.download + +import com.itsaky.androidide.plugins.PluginLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.appdevforall.getaimodels.catalog.CatalogEntry +import java.io.File +import java.io.InputStream + +/** Opens the bytes to hash. A function so the gate never learns how they are reached. */ +fun interface ByteSource { + /** A fresh stream positioned at the first byte; the gate closes it. */ + fun open(): InputStream +} + +/** + * What to run the gate over. [path] is the file's absolute path when it has one, and null when the + * bytes are only reachable through the downloads provider - which is hashable but neither + * recordable across restarts nor deletable from here. + */ +data class VerifyTarget( + val displayName: String, + val path: String?, + val bytes: ByteSource +) { + companion object { + fun ofFile(file: File): VerifyTarget = + VerifyTarget(file.absolutePath, file.absolutePath) { file.inputStream() } + } +} + +/** Outcome of the SHA-256 gate. Deliberately says nothing about UI state or events. */ +sealed interface VerifyResult { + + /** + * The hash matched. [recorded] is false when the target had no path to persist, so the badge + * will not survive a restart - the file is verified, just not remembered. + */ + data class Matched(val displayPath: String, val recorded: Boolean) : VerifyResult + + /** The hash did not match. Any record for the entry has already been dropped. */ + object Mismatched : VerifyResult + + /** The path was recorded but nothing is there any more. */ + object Missing : VerifyResult + + /** The bytes exist but could not be read through. */ + data class Unreadable(val reason: String) : VerifyResult +} + +/** + * The SHA-256 gate and the record of what has passed it: hashing, comparison against the catalog, + * persistence, and deletion of a file this plugin wrote. Deleting a *failed download* is not here - + * that is DownloadManager's job and lives in [ModelDownloader], which holds the download id. + * + * Free of Android APIs beyond the logger, so it unit-tests against temp files and a fake store. + */ +class ModelFileGate( + private val store: ModelRecordStore, + private val logger: PluginLogger +) { + + /** Records for every entry that has passed the gate, keyed by entry id. */ + suspend fun records(): Map = store.all() + + /** Drops an entry's record without touching the file. */ + suspend fun forget(entryId: String) = store.remove(entryId) + + /** + * Hashes [target] and compares it with [entry]'s catalogued checksum, persisting a record on a + * match. A mismatch drops any existing record: whatever it claimed no longer holds. + */ + suspend fun verify(entry: CatalogEntry, target: VerifyTarget): VerifyResult { + if (target.path != null && !File(target.path).isFile) { + store.remove(entry.id) + return VerifyResult.Missing + } + + val actual = try { + withContext(Dispatchers.IO) { target.bytes.open().use { Sha256.of(it) } } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Could not hash ${target.displayName}", t) + return VerifyResult.Unreadable("could not read the file to verify it") + } + + if (!actual.equals(entry.sha256, ignoreCase = true)) { + logger.error( + "Checksum mismatch for ${target.displayName}: expected ${entry.sha256}, got $actual" + ) + store.remove(entry.id) + return VerifyResult.Mismatched + } + + logger.info("Verified ${target.displayName} (sha256 ${entry.sha256})") + val path = target.path + ?: return VerifyResult.Matched(target.displayName, recorded = false) + + val file = File(path) + store.put( + entry.id, + VerifiedModel( + path = path, + sizeBytes = file.length(), + verifiedAtEpochMs = System.currentTimeMillis() + ) + ) + return VerifyResult.Matched(path, recorded = true) + } + + /** + * Deletes a file that passed the gate and drops its record. [path] must come from a record, never + * from the catalogued file name - a same-named file the user put there is not ours. Already gone + * counts as deleted: the intent is satisfied and the stale record still clears. + */ + suspend fun deleteRecorded(entryId: String, path: String): Boolean { + val file = File(path) + val gone = withContext(Dispatchers.IO) { !file.exists() || file.delete() } + if (gone) { + store.remove(entryId) + logger.info("Deleted ${file.absolutePath}") + } else { + logger.warn("Could not delete ${file.absolutePath}") + } + return gone + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/Sha256.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/Sha256.kt new file mode 100644 index 00000000..0be32919 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/Sha256.kt @@ -0,0 +1,36 @@ +package org.appdevforall.getaimodels.download + +import kotlinx.coroutines.ensureActive +import java.io.File +import java.io.InputStream +import java.security.MessageDigest +import kotlin.coroutines.coroutineContext + +object Sha256 { + + private const val BUFFER_BYTES = 1 shl 20 // 1 MiB: model files run to several GB + + /** + * Streams [file] and returns its lower-case hex SHA-256. Never reads the whole file into + * memory, and checks for cancellation between chunks so tearing the plugin down mid-verify + * does not leave the CPU hashing gigabytes. + */ + suspend fun of(file: File): String = file.inputStream().use { of(it) } + + /** + * The same digest over any stream, for bytes with no filesystem path - a finished download that + * DownloadManager exposes only through the downloads provider. Closing [input] stays the + * caller's job, as everywhere else an [InputStream] is passed in. + */ + suspend fun of(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStore.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStore.kt new file mode 100644 index 00000000..4bbc27f9 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStore.kt @@ -0,0 +1,114 @@ +package org.appdevforall.getaimodels.download + +import android.content.SharedPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File + +/** What was verified, and the facts needed to cheaply re-check it after a restart. */ +data class VerifiedModel( + /** Absolute path written to; DownloadManager may have suffixed the catalogued name. */ + val path: String, + /** Size at verification time; a stat against this catches every truncated file for free. */ + val sizeBytes: Long, + val verifiedAtEpochMs: Long +) { + + fun toJson(): String = JSONObject() + .put(KEY_PATH, path) + .put(KEY_SIZE, sizeBytes) + .put(KEY_VERIFIED_AT, verifiedAtEpochMs) + .toString() + + companion object { + private const val KEY_PATH = "path" + private const val KEY_SIZE = "sizeBytes" + private const val KEY_VERIFIED_AT = "verifiedAtEpochMs" + + /** Null for anything unreadable: a stale or hand-edited record is dropped, never crashes. */ + fun fromJson(raw: String): VerifiedModel? = runCatching { + val o = JSONObject(raw) + VerifiedModel( + path = o.getString(KEY_PATH), + sizeBytes = o.getLong(KEY_SIZE), + verifiedAtEpochMs = o.optLong(KEY_VERIFIED_AT, 0L) + ) + }.getOrNull()?.takeIf { it.path.isNotBlank() && it.sizeBytes > 0 } + } +} + +enum class DiskStatus { + /** Nothing at the recorded path - the user moved or deleted it. */ + ABSENT, + + /** Present, but not the size we verified - usually replaced or truncated. */ + SIZE_MISMATCH, + + /** Same path, same length as when the SHA-256 passed. */ + MATCHES +} + +/** + * The record side of the SHA-256 gate, as an interface so [ModelFileGate] can be unit-tested with an + * in-memory fake instead of Android's SharedPreferences. + */ +interface ModelRecordStore { + suspend fun all(): Map + suspend fun put(entryId: String, record: VerifiedModel) + suspend fun remove(entryId: String) +} + +/** One stat call. Pure enough to unit-test against temp files. */ +object DiskCheck { + fun status(path: String, expectedSizeBytes: Long): DiskStatus { + val file = File(path) + return when { + !file.isFile -> DiskStatus.ABSENT + file.length() != expectedSizeBytes -> DiskStatus.SIZE_MISMATCH + else -> DiskStatus.MATCHES + } + } +} + +/** + * Remembers which entries passed the SHA-256 gate, in the plugin's own SharedPreferences (one JSON + * string per entry id). Persisted rather than recomputed because hashing costs seconds to minutes. + * Every method suspends onto [Dispatchers.IO], so no caller can touch the disk from the main thread. + */ +class VerifiedModelStore( + private val preferencesProvider: () -> SharedPreferences +) : ModelRecordStore { + + private val initMutex = Mutex() + + @Volatile + private var cached: SharedPreferences? = null + + // Mutex-guarded so concurrent first callers cannot each open a SharedPreferences instance. + private suspend fun preferences(): SharedPreferences = + cached ?: initMutex.withLock { + cached ?: withContext(Dispatchers.IO) { preferencesProvider() }.also { cached = it } + } + + override suspend fun all(): Map = withContext(Dispatchers.IO) { + preferences().all.mapNotNull { (entryId, raw) -> + (raw as? String)?.let(VerifiedModel::fromJson)?.let { entryId to it } + }.toMap() + } + + /** Uses apply(): the in-memory map updates at once and the disk write never blocks a caller. */ + override suspend fun put(entryId: String, record: VerifiedModel) { + withContext(Dispatchers.IO) { + preferences().edit().putString(entryId, record.toJson()).apply() + } + } + + override suspend fun remove(entryId: String) { + withContext(Dispatchers.IO) { + preferences().edit().remove(entryId).apply() + } + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/net/NetworkStatus.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/net/NetworkStatus.kt new file mode 100644 index 00000000..3961eb06 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/net/NetworkStatus.kt @@ -0,0 +1,40 @@ +package org.appdevforall.getaimodels.net + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build + +/** Whether a download may start, and whether the user has to be warned first. */ +enum class NetworkStatus { + /** No usable connection - block the download with a message. */ + UNAVAILABLE, + + /** Connected over a metered link (cellular, metered hotspot) - warn before downloading. */ + METERED, + + /** Connected over an unmetered link - download straight away. */ + UNMETERED; + + val isConnected: Boolean get() = this != UNAVAILABLE +} + +object NetworkProbe { + + fun current(context: Context): NetworkStatus { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return NetworkStatus.UNAVAILABLE + if (!isConnected(cm)) return NetworkStatus.UNAVAILABLE + // Covers cellular and user-flagged metered Wi-Fi on every supported API level. + return if (cm.isActiveNetworkMetered) NetworkStatus.METERED else NetworkStatus.UNMETERED + } + + private fun isConnected(cm: ConnectivityManager): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + @Suppress("DEPRECATION") + return cm.activeNetworkInfo?.isConnected == true + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelCatalogFragment.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelCatalogFragment.kt new file mode 100644 index 00000000..627cc9b1 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelCatalogFragment.kt @@ -0,0 +1,261 @@ +package org.appdevforall.getaimodels.ui + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeTooltipService +import kotlinx.coroutines.launch +import org.appdevforall.getaimodels.GetAiModelsPlugin +import org.appdevforall.getaimodels.GetAiModelsRuntime +import org.appdevforall.getaimodels.R +import org.appdevforall.getaimodels.catalog.ByteSize +import org.appdevforall.getaimodels.catalog.CatalogEntry +import org.appdevforall.getaimodels.catalog.CatalogLoader +import org.appdevforall.getaimodels.download.DownloadEvent +import org.appdevforall.getaimodels.download.ModelDownloader +import org.appdevforall.getaimodels.net.NetworkProbe +import org.appdevforall.getaimodels.net.NetworkStatus + +/** + * The "Get AI Models" bottom-sheet tab: the catalog list plus the connectivity and metered-consent + * gates a download passes before it starts. Owns no download state - it reflects + * [ModelDownloader.states] and its events, so reopening the tab mid-download shows the truth. + */ +class ModelCatalogFragment : Fragment() { + + private companion object { + const val TOOLTIP_CATEGORY = "plugin_${GetAiModelsPlugin.PLUGIN_ID}" + } + + private var recycler: RecyclerView? = null + private var errorView: TextView? = null + private var adapter: ModelRowAdapter? = null + private var tooltipService: IdeTooltipService? = null + private var entries: List = emptyList() + + /** Routes inflation through the host so layouts track the IDE's light/dark setting. */ + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(GetAiModelsPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_model_catalog, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + tooltipService = runCatching { + PluginFragmentHelper.getServiceRegistry(GetAiModelsPlugin.PLUGIN_ID) + ?.get(IdeTooltipService::class.java) + }.getOrNull() + + recycler = view.findViewById(R.id.recyclerModels) + errorView = view.findViewById(R.id.tvCatalogError) + + // A bundled asset, so a failure here is a broken build, not something worth retrying. + entries = runCatching { CatalogLoader.load(requireContext().assets) } + .onFailure { showCatalogError(it) } + .getOrDefault(emptyList()) + if (entries.isEmpty()) return + + val rowAdapter = ModelRowAdapter( + entries = entries, + onDownload = ::requestDownload, + onCancel = ::requestCancel, + onVerify = ::requestVerify, + onDelete = ::confirmDelete, + onLongPress = ::showTooltip, + rowTooltipTag = GetAiModelsPlugin.TOOLTIP_TAG_ROW, + downloadTooltipTag = GetAiModelsPlugin.TOOLTIP_TAG_DOWNLOAD, + verifyTooltipTag = GetAiModelsPlugin.TOOLTIP_TAG_VERIFY, + cancelTooltipTag = GetAiModelsPlugin.TOOLTIP_TAG_CANCEL, + deleteTooltipTag = GetAiModelsPlugin.TOOLTIP_TAG_DELETE + ) + adapter = rowAdapter + recycler?.layoutManager = LinearLayoutManager(requireContext()) + recycler?.adapter = rowAdapter + + observeDownloads() + } + + private fun observeDownloads() { + val downloader = GetAiModelsRuntime.downloader() ?: return + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { downloader.states.collect { adapter?.submitStates(it) } } + launch { downloader.events.collect(::onDownloadEvent) } + } + } + } + + /** Connectivity and metered gates, then hand off to [ModelDownloader]. */ + private fun requestDownload(entry: CatalogEntry) { + val downloader = GetAiModelsRuntime.downloader() ?: run { + snack(getString(R.string.downloader_unavailable)) + return + } + + when (NetworkProbe.current(requireContext())) { + NetworkStatus.UNAVAILABLE -> snack(getString(R.string.no_network_message)) + + NetworkStatus.METERED -> confirmMetered(entry) { + startDownload(downloader, entry, allowOverMetered = true) + } + + NetworkStatus.UNMETERED -> + startDownload(downloader, entry, allowOverMetered = false) + } + } + + /** Stops an in-flight transfer. DownloadManager removes the partial file itself. */ + private fun requestCancel(entry: CatalogEntry) { + GetAiModelsRuntime.downloader()?.cancel(entry.id) + } + + /** Re-hashes a file already on disk; local bytes only, so no connectivity gate applies. */ + private fun requestVerify(entry: CatalogEntry, path: String) { + val downloader = GetAiModelsRuntime.downloader() ?: run { + snack(getString(R.string.downloader_unavailable)) + return + } + downloader.verifyExisting(entry, path) + } + + /** + * Confirms first: this removes gigabytes irreversibly and there is no trash. The dialog names the + * exact file, and a row whose file has changed since verification gets a blunter warning. + */ + private fun confirmDelete(entry: CatalogEntry, path: String, wasVerified: Boolean) { + val message = if (wasVerified) { + getString( + R.string.delete_dialog_message, + entry.name, + ByteSize.format(entry.sizeBytes), + path + ) + } else { + getString(R.string.delete_dialog_message_changed, entry.name, path) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.delete_dialog_title) + .setMessage(message) + .setPositiveButton(R.string.delete_dialog_confirm) { _, _ -> + GetAiModelsRuntime.downloader()?.deleteDownloadedFile(entry, path) + ?: snack(getString(R.string.downloader_unavailable)) + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun startDownload( + downloader: ModelDownloader, + entry: CatalogEntry, + allowOverMetered: Boolean + ) { + downloader.enqueue(entry, allowOverMetered) + snack(getString(R.string.snack_download_started, entry.name)) + } + + private fun confirmMetered(entry: CatalogEntry, onProceed: () -> Unit) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.metered_dialog_title) + .setMessage( + getString( + R.string.metered_dialog_message, + entry.name, + ByteSize.format(entry.sizeBytes) + ) + ) + .setPositiveButton(R.string.metered_dialog_proceed) { _, _ -> onProceed() } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun onDownloadEvent(event: DownloadEvent) { + when (event) { + is DownloadEvent.Verified -> snack( + getString(R.string.snack_verified, event.modelName) + ) + + is DownloadEvent.ChecksumFailed -> offerRedownload(event.entryId, event.modelName) + + // Nothing was deleted, so there is no re-download to offer - the row's Retry covers it. + is DownloadEvent.VerifyFailed -> snack( + getString(R.string.snack_verify_failed, event.modelName) + ) + + is DownloadEvent.Cancelled -> snack( + getString(R.string.snack_download_cancelled, event.modelName) + ) + + is DownloadEvent.Deleted -> snack(getString(R.string.snack_deleted, event.modelName)) + + is DownloadEvent.DeleteFailed -> snack( + getString(R.string.snack_delete_failed, event.modelName) + ) + + is DownloadEvent.TransportFailed -> snack( + getString(R.string.snack_download_failed, event.modelName, event.reason) + ) + } + } + + /** The checksum gate deleted the file; the Gherkin requires offering the download again. */ + private fun offerRedownload(entryId: String, modelName: String) { + val entry = entries.firstOrNull { it.id == entryId } ?: return + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.checksum_dialog_title) + .setMessage(getString(R.string.checksum_dialog_message, modelName)) + .setPositiveButton(R.string.checksum_dialog_retry) { _, _ -> requestDownload(entry) } + .setNegativeButton(R.string.action_not_now, null) + .show() + } + + private fun showTooltip(anchor: View, tooltipTag: String) { + val service = tooltipService ?: run { + snack(getString(R.string.help_unavailable)) + return + } + // Explicit-category overload; the 2-arg one resolves elsewhere and renders "n/a". + service.showTooltip(anchor, TOOLTIP_CATEGORY, tooltipTag) + } + + /** + * Snackbar, never Toast: a Toast from a plugin adds a window under a package id that is not a real + * installed UID, throwing SecurityException and taking the IDE down with it. + */ + private fun snack(message: String) { + val root = view ?: return + Snackbar.make(root, message, Snackbar.LENGTH_LONG).show() + } + + private fun showCatalogError(cause: Throwable) { + errorView?.visibility = View.VISIBLE + recycler?.visibility = View.GONE + Log.e("GetAiModels", "Could not read the bundled catalog", cause) + } + + override fun onDestroyView() { + super.onDestroyView() + recycler?.adapter = null + recycler = null + errorView = null + adapter = null + tooltipService = null + } +} diff --git a/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelRowAdapter.kt b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelRowAdapter.kt new file mode 100644 index 00000000..789ed7e2 --- /dev/null +++ b/get-ai-models/src/main/kotlin/org/appdevforall/getaimodels/ui/ModelRowAdapter.kt @@ -0,0 +1,315 @@ +package org.appdevforall.getaimodels.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import org.appdevforall.getaimodels.R +import org.appdevforall.getaimodels.catalog.ByteSize +import org.appdevforall.getaimodels.catalog.CatalogEntry +import org.appdevforall.getaimodels.download.DownloadState +import org.appdevforall.getaimodels.download.Phase + +/** + * One row per catalog file: name, compact spec line, action button, tap-to-expand detail. In flight, + * Download becomes a Cancel control whose background doubles as the progress indicator - an approved + * deviation from "no in-app per-row progress bar", since the notification can be dismissed. + */ +class ModelRowAdapter( + private val entries: List, + private val onDownload: (CatalogEntry) -> Unit, + private val onCancel: (CatalogEntry) -> Unit, + private val onVerify: (CatalogEntry, path: String) -> Unit, + private val onDelete: (CatalogEntry, path: String, wasVerified: Boolean) -> Unit, + private val onLongPress: (anchor: View, tooltipTag: String) -> Unit, + private val rowTooltipTag: String, + private val downloadTooltipTag: String, + private val verifyTooltipTag: String, + private val cancelTooltipTag: String, + private val deleteTooltipTag: String +) : RecyclerView.Adapter() { + + private companion object { + /** ClipDrawable level range. */ + const val MAX_LEVEL = 10_000 + } + + private val expanded = mutableSetOf() + private var states: Map = emptyMap() + + /** Applies new download states, refreshing only the rows whose state actually changed. */ + fun submitStates(next: Map) { + val previous = states + states = next + entries.forEachIndexed { index, entry -> + val before = previous[entry.id] ?: DownloadState.Idle + val after = next[entry.id] ?: DownloadState.Idle + if (before != after) notifyItemChanged(index) + } + } + + override fun getItemCount(): Int = entries.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RowHolder { + // parent.context is the host's theme-aware plugin context, so values-night/ resolves. + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_model, parent, false) + return RowHolder(view) + } + + override fun onBindViewHolder(holder: RowHolder, position: Int) { + holder.bind(entries[position]) + } + + inner class RowHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + private val name: TextView = itemView.findViewById(R.id.tvModelName) + private val spec: TextView = itemView.findViewById(R.id.tvModelSpec) + private val status: TextView = itemView.findViewById(R.id.tvModelStatus) + private val detail: View = itemView.findViewById(R.id.groupDetail) + private val description: TextView = itemView.findViewById(R.id.tvModelDescription) + private val gatesUnproven: TextView = itemView.findViewById(R.id.tvGatesUnproven) + private val publisher: TextView = itemView.findViewById(R.id.tvModelPublisher) + private val minRam: TextView = itemView.findViewById(R.id.tvModelMinRam) + private val license: TextView = itemView.findViewById(R.id.tvModelLicense) + private val download: MaterialButton = itemView.findViewById(R.id.btnDownload) + private val verify: Button = itemView.findViewById(R.id.btnVerify) + private val delete: Button = itemView.findViewById(R.id.btnDelete) + private val cancelControl: View = itemView.findViewById(R.id.cancelControl) + private val progressFill: View = itemView.findViewById(R.id.progressFill) + private val cancelLabel: TextView = itemView.findViewById(R.id.tvCancel) + + init { + // Keeps the fill inside the track's rounded corners. + cancelControl.clipToOutline = true + // Verify and Delete are real Buttons; Cancel has to be a container so the progress fill + // can sit behind its label, so only that one declares the role itself. + announceAsButton(cancelControl) + } + + /** Makes a non-Button view report itself as a button to accessibility services. */ + private fun announceAsButton(view: View) { + ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.className = Button::class.java.name + } + }) + } + + fun bind(entry: CatalogEntry) { + val resources = itemView.resources + name.text = entry.name + spec.text = resources.getString( + R.string.spec_line, + entry.parameters, + entry.quantization, + ByteSize.format(entry.sizeBytes) + ) + + description.text = entry.description + // Gates 3 and 4 are proven by a harness that does not exist yet; say so in the UI. + gatesUnproven.visibility = + if (entry.behaviouralGatesVerified) View.GONE else View.VISIBLE + publisher.text = resources.getString(R.string.publisher_label, entry.publisher) + minRam.text = resources.getString( + R.string.min_ram_label, + ByteSize.formatWholeGb(entry.minRamBytes) + ) + license.text = resources.getString( + R.string.license_label, + entry.license, + "${entry.contextTokens / 1024}k", + entry.baseModel + ) + detail.visibility = if (entry.id in expanded) View.VISIBLE else View.GONE + + bindState(entry, states[entry.id] ?: DownloadState.Idle) + + itemView.setOnClickListener { toggle(entry) } + itemView.setOnLongClickListener { + onLongPress(itemView, rowTooltipTag) + true + } + download.setOnLongClickListener { + onLongPress(download, downloadTooltipTag) + true + } + download.contentDescription = + resources.getString(R.string.content_description_download, entry.name) + verify.setOnLongClickListener { + onLongPress(verify, verifyTooltipTag) + true + } + verify.contentDescription = + resources.getString(R.string.content_description_verify, entry.name) + delete.setOnLongClickListener { + onLongPress(delete, deleteTooltipTag) + true + } + delete.contentDescription = + resources.getString(R.string.content_description_delete, entry.name) + cancelControl.setOnLongClickListener { + onLongPress(cancelControl, cancelTooltipTag) + true + } + cancelControl.contentDescription = + resources.getString(R.string.content_description_cancel, entry.name) + } + + private fun bindState(entry: CatalogEntry, state: DownloadState) { + val resources = itemView.resources + download.setOnClickListener(null) + verify.setOnClickListener(null) + delete.setOnClickListener(null) + cancelControl.setOnClickListener(null) + + // Cancel takes the download button's place in flight rather than sitting beside it. + val inFlight = state is DownloadState.Downloading + cancelControl.visibility = if (inFlight) View.VISIBLE else View.GONE + download.visibility = if (inFlight) View.GONE else View.VISIBLE + // Verified with a path we wrote and can find again. A verified-but-pathless row is + // deliberately excluded: neither re-verifying nor deleting has anything to act on. + val verifiedOnDisk = (state as? DownloadState.Verified)?.takeIf { it.onDisk } + + // Only where a record gives a path - never one composed from the catalogued file name. + val recordedPath = verifiedOnDisk?.path ?: (state as? DownloadState.Changed)?.path + + // A Changed row re-verifies via the main button instead, so this stays hidden there. + verify.visibility = if (verifiedOnDisk != null) View.VISIBLE else View.GONE + verifiedOnDisk?.let { verified -> + verify.setOnClickListener { onVerify(entry, verified.path) } + } + delete.visibility = if (recordedPath != null) View.VISIBLE else View.GONE + if (recordedPath != null) { + delete.setOnClickListener { + onDelete(entry, recordedPath, state is DownloadState.Verified) + } + } + + when (state) { + DownloadState.Idle -> { + download.isEnabled = true + download.setText(R.string.action_download) + status.visibility = View.GONE + download.setOnClickListener { onDownload(entry) } + } + + is DownloadState.Downloading -> bindInFlight(entry, state) + + DownloadState.Verifying -> { + download.isEnabled = false + download.setText(R.string.state_verifying) + showStatus(R.string.status_verifying, R.color.status_neutral_text) + } + + is DownloadState.Verified -> { + download.isEnabled = false + download.setText(R.string.state_downloaded) + status.visibility = View.VISIBLE + status.text = if (state.onDisk) { + resources.getString(R.string.status_verified, state.path) + } else { + // No path came back, so the folder cannot be named and the badge is + // session-only; say so rather than imply it will still be here tomorrow. + resources.getString(R.string.status_verified_no_path, state.path) + } + status.setTextColor(ContextCompat.getColor(itemView.context, R.color.status_success_text)) + } + + is DownloadState.Changed -> { + download.isEnabled = true + download.setText(R.string.action_verify) + status.visibility = View.VISIBLE + status.text = resources.getString(R.string.status_changed, state.path) + status.setTextColor( + ContextCompat.getColor(itemView.context, R.color.status_error_text) + ) + download.setOnClickListener { onVerify(entry, state.path) } + } + + is DownloadState.Failed -> { + download.isEnabled = true + download.setText(R.string.action_retry) + status.visibility = View.VISIBLE + status.text = if (state.checksumMismatch) { + resources.getString(R.string.status_checksum_failed) + } else { + resources.getString(R.string.status_failed, state.message) + } + status.setTextColor(ContextCompat.getColor(itemView.context, R.color.status_error_text)) + download.setOnClickListener { onDownload(entry) } + } + } + } + + /** + * Draws the in-flight row: the fill behind the Cancel label is the progress, and the status + * line says whether bytes are actually moving. Both matter because the system notification + * can be dismissed. + */ + private fun bindInFlight(entry: CatalogEntry, state: DownloadState.Downloading) { + val resources = itemView.resources + + // ClipDrawable levels run 0..10000, so no measured width is needed. + progressFill.background?.level = (state.fraction * MAX_LEVEL).toInt() + cancelLabel.text = if (state.bytesSoFar > 0L) { + resources.getString( + R.string.cancel_label_percent, + (state.fraction * 100).toInt() + ) + } else { + resources.getString(R.string.action_cancel_download) + } + cancelControl.setOnClickListener { onCancel(entry) } + + val soFar = ByteSize.format(state.bytesSoFar) + val total = ByteSize.format(state.totalBytes) + status.visibility = View.VISIBLE + status.text = when (state.phase) { + Phase.PENDING -> resources.getString(R.string.status_pending) + Phase.RUNNING -> resources.getString(R.string.status_downloading, soFar, total) + Phase.PAUSED_WAITING_FOR_WIFI -> + resources.getString(R.string.status_paused_wifi, soFar, total) + + Phase.PAUSED_WAITING_FOR_NETWORK -> + resources.getString(R.string.status_paused_network, soFar, total) + + Phase.PAUSED_WAITING_TO_RETRY -> + resources.getString(R.string.status_paused_retry, soFar, total) + + Phase.PAUSED_UNKNOWN -> + resources.getString(R.string.status_paused_unknown, soFar, total) + } + // A pause is not an error, but nothing moves until something changes - so it stands out. + status.setTextColor( + ContextCompat.getColor( + itemView.context, + if (state.isPaused) R.color.status_error_text else R.color.status_neutral_text + ) + ) + } + + private fun showStatus(textRes: Int, colorRes: Int) { + status.visibility = View.VISIBLE + status.setText(textRes) + status.setTextColor(ContextCompat.getColor(itemView.context, colorRes)) + } + + private fun toggle(entry: CatalogEntry) { + val position = bindingAdapterPosition + if (position == RecyclerView.NO_POSITION) return + if (!expanded.remove(entry.id)) expanded.add(entry.id) + notifyItemChanged(position) + } + } +} diff --git a/get-ai-models/src/main/res/drawable/bg_cancel_progress_fill.xml b/get-ai-models/src/main/res/drawable/bg_cancel_progress_fill.xml new file mode 100644 index 00000000..665214c0 --- /dev/null +++ b/get-ai-models/src/main/res/drawable/bg_cancel_progress_fill.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/get-ai-models/src/main/res/drawable/bg_cancel_track.xml b/get-ai-models/src/main/res/drawable/bg_cancel_track.xml new file mode 100644 index 00000000..6c0a14ec --- /dev/null +++ b/get-ai-models/src/main/res/drawable/bg_cancel_track.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/get-ai-models/src/main/res/drawable/bg_delete_button.xml b/get-ai-models/src/main/res/drawable/bg_delete_button.xml new file mode 100644 index 00000000..d3d2886a --- /dev/null +++ b/get-ai-models/src/main/res/drawable/bg_delete_button.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/get-ai-models/src/main/res/drawable/bg_verify_button.xml b/get-ai-models/src/main/res/drawable/bg_verify_button.xml new file mode 100644 index 00000000..4c486d3c --- /dev/null +++ b/get-ai-models/src/main/res/drawable/bg_verify_button.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/get-ai-models/src/main/res/drawable/ic_download.xml b/get-ai-models/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..f4a9eb3a --- /dev/null +++ b/get-ai-models/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/get-ai-models/src/main/res/layout/fragment_model_catalog.xml b/get-ai-models/src/main/res/layout/fragment_model_catalog.xml new file mode 100644 index 00000000..e83fbb2e --- /dev/null +++ b/get-ai-models/src/main/res/layout/fragment_model_catalog.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/get-ai-models/src/main/res/layout/item_model.xml b/get-ai-models/src/main/res/layout/item_model.xml new file mode 100644 index 00000000..1b0e2200 --- /dev/null +++ b/get-ai-models/src/main/res/layout/item_model.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/get-ai-models/src/main/res/values-night/colors.xml b/get-ai-models/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..f55317fe --- /dev/null +++ b/get-ai-models/src/main/res/values-night/colors.xml @@ -0,0 +1,34 @@ + + + #88D6B8 + #00382A + #00513D + #A4F2D3 + #B3CCBF + #1F352C + #191C1A + #E1E3DF + #BFC9C1 + #89938C + #404943 + @color/plugin_on_surface_variant + @color/plugin_primary + #FFB4AB + + + #20241F + #5A645D + #004E3B + #007658 + #C6F7E2 + + + diff --git a/get-ai-models/src/main/res/values/colors.xml b/get-ai-models/src/main/res/values/colors.xml new file mode 100644 index 00000000..3f10abc6 --- /dev/null +++ b/get-ai-models/src/main/res/values/colors.xml @@ -0,0 +1,51 @@ + + + #006C4C + #FFFFFF + #89F8C7 + #002114 + #4D6357 + #FFFFFF + #FBFDF9 + #191C1A + #404943 + #707973 + #BFC9C1 + @color/plugin_on_surface_variant + @color/plugin_primary + #BA1A1A + + + #EDF2EE + #9BA8A0 + #4FE3AE + #B6F8DD + #002114 + + + #1565C0 + #0D47A1 + #FFFFFF + + + #B3261E + #8C1D18 + #FFFFFF + diff --git a/get-ai-models/src/main/res/values/strings.xml b/get-ai-models/src/main/res/values/strings.xml new file mode 100644 index 00000000..4b27693d --- /dev/null +++ b/get-ai-models/src/main/res/values/strings.xml @@ -0,0 +1,66 @@ + + Get AI Models + + + %1$s · %2$s · %3$s + Publisher: %1$s + Minimum RAM: %1$s (informational — your device is not checked) + Not yet validated in the AI plugin — the tool-use evaluation is pending + License: %1$s · Context: %2$s tokens · Base model: %3$s + The bundled model catalog could not be read. + + + Download + Retry + Verify + Verify file again + Delete file + Verifying… + Downloaded + Cancel + Cancel · %1$d%% + Cancel downloading %1$s + Queued — waiting to start + Downloading %1$s of %2$s + Paused — waiting for Wi-Fi · %1$s of %2$s + Paused — no connection · %1$s of %2$s + Paused — retrying · %1$s of %2$s + Paused · %1$s of %2$s + Checking SHA-256… + Verified and saved to %1$s + Verified %1$s — find it in your Downloads app. Android did not report where it was saved, so this plugin cannot show it again after a restart, or delete it for you. + The file at %1$s is no longer the size that was verified — verify it again + Checksum did not match — the file was deleted + Could not download: %1$s + Download %1$s + Verify %1$s again + Delete the downloaded file for %1$s + + + Delete this file? + %1$s (%2$s)\n\n%3$s\n\nThis permanently deletes the file from your device and cannot be undone. You can download it again later. + %1$s\n\n%2$s\n\nThis file no longer matches the size that was verified, so it may have been replaced or edited since. Deleting it is permanent and cannot be undone. + Delete + Deleted %1$s + Could not delete %1$s — the file is still on your device + + + No internet connection. + Metered connection + This connection is metered. Downloading %1$s (%2$s) may use your mobile data allowance.\n\nDownload anyway? + Download + Cancel + + + Checksum failed + %1$s finished downloading but its SHA-256 did not match the catalog, so the file was deleted.\n\nDownload it again? + Download again + Not now + Downloading %1$s — progress is on the row and in the notification + Cancelled %1$s + %1$s verified and saved to Downloads + %1$s does not match the catalog checksum — the file was left in place + %1$s could not be downloaded: %2$s + Downloads are unavailable — the plugin is not active. + Help is not available. + diff --git a/get-ai-models/src/main/res/values/styles.xml b/get-ai-models/src/main/res/values/styles.xml new file mode 100644 index 00000000..dd87c338 --- /dev/null +++ b/get-ai-models/src/main/res/values/styles.xml @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoaderTest.kt b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoaderTest.kt new file mode 100644 index 00000000..50263110 --- /dev/null +++ b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/catalog/CatalogLoaderTest.kt @@ -0,0 +1,87 @@ +package org.appdevforall.getaimodels.catalog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Guards the *shipped* catalog asset, not a fixture. Curation is hand-editing models.json, where a + * typo yields a plugin that shows nothing or downloads a file it can never verify - so the real asset + * is parsed and validated on every build. + */ +class CatalogLoaderTest { + + private val asset = File("src/main/assets/${CatalogLoader.ASSET_PATH}") + + private fun catalog(): List = CatalogLoader.parse(asset.readText()) + + @Test + fun givenTheShippedCatalogAsset_whenParsed_thenEntriesAreLoaded() { + assertTrue("missing asset: ${asset.absolutePath}", asset.isFile) + assertTrue("catalog is empty", catalog().isNotEmpty()) + } + + @Test + fun givenTheShippedCatalogAsset_whenUrlsAreInspected_thenEachPinsACommitRevision() { + // A /resolve/main/ URL would let the file change under a pinned sha256. + catalog().forEach { entry -> + assertTrue( + "${entry.id}: url must not resolve a branch: ${entry.url}", + !entry.url.contains("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/resolve/main/") + ) + assertTrue( + "${entry.id}: url must end in the catalogued file name", + entry.url.endsWith("/${entry.fileName}") + ) + } + } + + @Test + fun givenTheShippedCatalogAsset_whenStructureIsChecked_thenEveryEntryIsWellFormed() { + // The open-licence and 16k-context gates are deliberately NOT asserted: the ticket owner + // waived both for the current catalog, and docs/CURATION.md records which entries break them. + catalog().forEach { entry -> + // Gate 6 still holds for every entry: one file, no split-GGUF parts. + assertTrue( + "${entry.id}: looks like a split GGUF part: ${entry.fileName}", + !Regex("-\\d{5}-of-\\d{5}\\.gguf$").containsMatchIn(entry.fileName) + ) + assertTrue("${entry.id}: context window must be positive", entry.contextTokens > 0) + } + } + + @Test + fun givenTheShippedCatalogAsset_whenGateStatusIsRead_thenEveryEntryDeclaresIt() { + // The harness does not exist yet, so no entry may claim the behavioural gates. + catalog().forEach { entry -> + assertTrue( + "${entry.id}: behavioural gates cannot be claimed until the harness proves them", + !entry.behaviouralGatesVerified + ) + } + } + + @Test + fun givenAnEntryMissingItsGateStatus_whenParsed_thenParsingIsRejected() { + // The field is last in each entry, so the preceding comma goes with it. + val broken = asset.readText() + .replace(Regex(",\\s*\"behaviouralGatesVerified\"\\s*:\\s*(true|false)"), "") + val failure = runCatching { CatalogLoader.parse(broken) }.exceptionOrNull() + assertTrue("expected a parse failure, got $failure", failure != null) + } + + @Test + fun givenACatalogWhoseChecksumIsNotASha256_whenParsed_thenParsingIsRejected() { + val broken = asset.readText().replace(catalog().first().sha256, "not-a-hash") + val failure = runCatching { CatalogLoader.parse(broken) }.exceptionOrNull() + assertTrue("expected a parse failure, got $failure", failure is IllegalArgumentException) + } + + @Test + fun givenByteCounts_whenFormatted_thenTheSpecLineUnitsAreProduced() { + assertEquals("2.33 GB", ByteSize.format(2_497_280_256L)) + assertEquals("610 MB", ByteSize.format(639_446_688L)) + assertEquals("6 GB", ByteSize.formatWholeGb(6L * 1024 * 1024 * 1024)) + } +} diff --git a/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadStateTest.kt b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadStateTest.kt new file mode 100644 index 00000000..8b3b208f --- /dev/null +++ b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadStateTest.kt @@ -0,0 +1,59 @@ +package org.appdevforall.getaimodels.download + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DownloadStateTest { + + private fun downloading(soFar: Long, total: Long, phase: Phase = Phase.RUNNING) = + DownloadState.Downloading(soFar, total, phase) + + @Test + fun givenPartialProgress_whenTheFractionIsComputed_thenItScalesFromEmptyToFull() { + assertEquals(0f, downloading(0, 1_000).fraction, 0.0001f) + assertEquals(0.25f, downloading(250, 1_000).fraction, 0.0001f) + assertEquals(1f, downloading(1_000, 1_000).fraction, 0.0001f) + } + + @Test + fun givenOverlongOrUnknownTotals_whenTheFractionIsComputed_thenItIsClampedWithoutDividingByZero() { + // DownloadManager can report more bytes than the total it first advertised. + assertEquals(1f, downloading(2_000, 1_000).fraction, 0.0001f) + // An unknown total must not produce NaN or Infinity, which would break setLevel(). + assertEquals(0f, downloading(500, 0).fraction, 0.0001f) + assertEquals(0f, downloading(500, -1).fraction, 0.0001f) + } + + @Test + fun givenEachPhase_whenIsPausedIsRead_thenOnlyThePausedPhasesReportTrue() { + assertFalse(downloading(0, 10, Phase.PENDING).isPaused) + assertFalse(downloading(5, 10, Phase.RUNNING).isPaused) + assertTrue(downloading(5, 10, Phase.PAUSED_WAITING_FOR_WIFI).isPaused) + assertTrue(downloading(5, 10, Phase.PAUSED_WAITING_FOR_NETWORK).isPaused) + assertTrue(downloading(5, 10, Phase.PAUSED_WAITING_TO_RETRY).isPaused) + assertTrue(downloading(5, 10, Phase.PAUSED_UNKNOWN).isPaused) + } + + @Test + fun givenEachState_whenIsBusyIsRead_thenOnlyInFlightAndVerifyingReportTrue() { + assertTrue(downloading(1, 10).isBusy) + assertTrue(DownloadState.Verifying.isBusy) + assertFalse(DownloadState.Idle.isBusy) + assertFalse(DownloadState.Verified("/sdcard/Download/m.gguf").isBusy) + assertFalse(DownloadState.Changed("/sdcard/Download/m.gguf").isBusy) + assertFalse(DownloadState.Failed("nope", false).isBusy) + } + + @Test + fun givenTwoProgressValues_whenComparedForEquality_thenDifferingByteCountsAreUnequal() { + // submitStates() repaints only on inequality, so equal byte counts would freeze the fill. + assertTrue(downloading(100, 1_000) != downloading(200, 1_000)) + assertTrue( + downloading(100, 1_000, Phase.RUNNING) != + downloading(100, 1_000, Phase.PAUSED_WAITING_FOR_WIFI) + ) + assertEquals(downloading(100, 1_000), downloading(100, 1_000)) + } +} diff --git a/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadedFileResolverTest.kt b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadedFileResolverTest.kt new file mode 100644 index 00000000..73eb6f7f --- /dev/null +++ b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/DownloadedFileResolverTest.kt @@ -0,0 +1,69 @@ +package org.appdevforall.getaimodels.download + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A content:// URI used to be handed to File(), whose path (`/all_downloads/42`) is not a filesystem + * path - so a finished download was reported as missing. Only a file:// URI or COLUMN_LOCAL_FILENAME + * yields a path; a bare content:// URI resolves to the provider instead of failing the download. + */ +class DownloadedFileResolverTest { + + @Test + fun givenAFileUri_whenResolved_thenThePathIsReturned() { + assertEquals( + DownloadedLocation.OnDisk("/storage/emulated/0/Download/model.gguf"), + DownloadedFileResolver.resolve( + localUri = "file:///storage/emulated/0/Download/model.gguf", + localFileName = null + ) + ) + } + + @Test + fun givenAContentUriAndALocalFileName_whenResolved_thenTheFileNameGivesThePath() { + assertEquals( + DownloadedLocation.OnDisk("/storage/emulated/0/Download/model.gguf"), + DownloadedFileResolver.resolve( + localUri = "content://downloads/all_downloads/42", + localFileName = "/storage/emulated/0/Download/model.gguf" + ) + ) + } + + @Test + fun givenOnlyAContentUri_whenResolved_thenTheProviderIsUsed() { + assertEquals( + DownloadedLocation.ViaProvider("content://downloads/all_downloads/42"), + DownloadedFileResolver.resolve( + localUri = "content://downloads/all_downloads/42", + localFileName = null + ) + ) + } + + /** The original defect: a provider URI must never come back as something File() will be given. */ + @Test + fun givenOnlyAContentUri_whenResolved_thenItIsNeverTreatedAsAPath() { + val resolved = DownloadedFileResolver.resolve("content://downloads/all_downloads/42", null) + assertFalse("a content:// URI resolved to a filesystem path", resolved is DownloadedLocation.OnDisk) + } + + @Test + fun givenNothingUsable_whenResolved_thenNothingIsReturned() { + assertNull(DownloadedFileResolver.resolve(null, null)) + assertNull(DownloadedFileResolver.resolve("file://", " ")) + assertNull(DownloadedFileResolver.resolve("", "")) + } + + @Test + fun givenAFileUriWithAnEncodedName_whenResolved_thenTheNameIsDecoded() { + assertEquals( + DownloadedLocation.OnDisk("/sdcard/Download/my model.gguf"), + DownloadedFileResolver.resolve("file:///sdcard/Download/my%20model.gguf", null) + ) + } +} diff --git a/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/ModelFileGateTest.kt b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/ModelFileGateTest.kt new file mode 100644 index 00000000..989f312a --- /dev/null +++ b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/ModelFileGateTest.kt @@ -0,0 +1,156 @@ +package org.appdevforall.getaimodels.download + +import com.itsaky.androidide.plugins.PluginLogger +import kotlinx.coroutines.runBlocking +import org.appdevforall.getaimodels.catalog.CatalogEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException + +/** + * The SHA-256 gate on its own: what it returns, and what it leaves in the record store afterwards. + * Runs off-device because [ModelFileGate] takes a [ModelRecordStore] rather than SharedPreferences. + */ +class ModelFileGateTest { + + @get:Rule + val folder = TemporaryFolder() + + /** sha256 of "hello" - the fixture every on-disk case below is checked against. */ + private val helloSha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + private val store = FakeRecordStore() + private val gate = ModelFileGate(store, SilentLogger) + + private fun entry(sha: String = helloSha) = CatalogEntry( + id = "test-entry", + name = "Test Model", + quantization = "Q4_K_M", + parameters = "0.5B", + fileName = "model.gguf", + sizeBytes = 5L, + sha256 = sha, + url = "https://example.invalid/model.gguf", + minRamBytes = 1L, + publisher = "test", + contextTokens = 32768, + license = "apache-2.0", + baseModel = "test/model", + description = "fixture", + behaviouralGatesVerified = false + ) + + private fun fileOf(contents: String): File = + folder.newFile("model.gguf").apply { writeText(contents) } + + @Test + fun givenAMatchingFile_whenVerified_thenItIsRecordedAtItsPath() = runBlocking { + val file = fileOf("hello") + + val result = gate.verify(entry(), VerifyTarget.ofFile(file)) + + assertEquals(VerifyResult.Matched(file.absolutePath, recorded = true), result) + val record = store.records["test-entry"] + assertEquals(file.absolutePath, record?.path) + assertEquals(5L, record?.sizeBytes) + } + + @Test + fun givenAMismatchingFile_whenVerified_thenNothingIsRecordedAndTheFileIsUntouched() = runBlocking { + val file = fileOf("hello") + store.records["test-entry"] = VerifiedModel(file.absolutePath, 5L, 1L) + + val result = gate.verify(entry(sha = "0".repeat(64)), VerifyTarget.ofFile(file)) + + assertEquals(VerifyResult.Mismatched, result) + // The stale record must go, but deleting a failed *download* is DownloadManager's job. + assertNull(store.records["test-entry"]) + assertTrue("the gate must not delete the file itself", file.exists()) + } + + @Test + fun givenAPathlessTarget_whenVerified_thenItPassesButIsNotRecorded() = runBlocking { + val target = VerifyTarget("model.gguf", path = null) { + ByteArrayInputStream("hello".toByteArray()) + } + + val result = gate.verify(entry(), target) + + assertEquals(VerifyResult.Matched("model.gguf", recorded = false), result) + assertTrue("a pathless pass has nothing to record", store.records.isEmpty()) + } + + @Test + fun givenARecordedPathThatIsGone_whenVerified_thenItIsMissingAndTheRecordIsDropped() = runBlocking { + val file = fileOf("hello") + store.records["test-entry"] = VerifiedModel(file.absolutePath, 5L, 1L) + assertTrue(file.delete()) + + val result = gate.verify(entry(), VerifyTarget.ofFile(file)) + + assertEquals(VerifyResult.Missing, result) + assertNull(store.records["test-entry"]) + } + + @Test + fun givenBytesThatCannotBeRead_whenVerified_thenItReportsUnreadableRatherThanThrowing() = + runBlocking { + val target = VerifyTarget("model.gguf", path = null) { throw IOException("no stream") } + + val result = gate.verify(entry(), target) + + assertTrue("expected Unreadable, got $result", result is VerifyResult.Unreadable) + } + + @Test + fun givenARecordedFile_whenDeleted_thenBothTheFileAndTheRecordAreGone() = runBlocking { + val file = fileOf("hello") + store.records["test-entry"] = VerifiedModel(file.absolutePath, 5L, 1L) + + assertTrue(gate.deleteRecorded("test-entry", file.absolutePath)) + + assertFalse(file.exists()) + assertNull(store.records["test-entry"]) + } + + @Test + fun givenAnAlreadyDeletedFile_whenDeleted_thenTheStaleRecordStillClears() = runBlocking { + val path = folder.root.resolve("never-existed.gguf").absolutePath + store.records["test-entry"] = VerifiedModel(path, 5L, 1L) + + assertTrue("already gone satisfies the intent", gate.deleteRecorded("test-entry", path)) + + assertNull(store.records["test-entry"]) + } + + private class FakeRecordStore : ModelRecordStore { + val records = mutableMapOf() + override suspend fun all(): Map = records.toMap() + override suspend fun put(entryId: String, record: VerifiedModel) { + records[entryId] = record + } + + override suspend fun remove(entryId: String) { + records.remove(entryId) + } + } + + private object SilentLogger : PluginLogger { + override val pluginId: String = "test" + override fun debug(message: String) = Unit + override fun debug(message: String, throwable: Throwable) = Unit + override fun info(message: String) = Unit + override fun info(message: String, throwable: Throwable) = Unit + override fun warn(message: String) = Unit + override fun warn(message: String, throwable: Throwable) = Unit + override fun error(message: String) = Unit + override fun error(message: String, throwable: Throwable) = Unit + } +} diff --git a/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStoreTest.kt b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStoreTest.kt new file mode 100644 index 00000000..6e0cd00d --- /dev/null +++ b/get-ai-models/src/test/kotlin/org/appdevforall/getaimodels/download/VerifiedModelStoreTest.kt @@ -0,0 +1,58 @@ +package org.appdevforall.getaimodels.download + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Covers the Android-free half of the persistence path: the record encoding and the stat-based + * revalidation deciding whether a remembered verification still holds. The SharedPreferences wrapper + * is a thin withContext(IO) shell and needs a device. + */ +class VerifiedModelStoreTest { + + @get:Rule + val folder = TemporaryFolder() + + @Test + fun givenAVerifiedRecord_whenEncodedAndDecoded_thenItIsUnchanged() { + val record = VerifiedModel("/sdcard/Download/model.gguf", 2_497_280_256L, 1_700_000_000_000L) + val restored = VerifiedModel.fromJson(record.toJson()) + assertEquals(record, restored) + } + + @Test + fun givenAnUnreadableRecord_whenDecoded_thenItIsDroppedRatherThanThrown() { + assertNull(VerifiedModel.fromJson("not json")) + assertNull(VerifiedModel.fromJson("{}")) + // A zero or negative size would make the stat check meaningless. + assertNull(VerifiedModel.fromJson("""{"path":"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/a/b.gguf","sizeBytes":0}""")) + assertNull(VerifiedModel.fromJson("""{"path":"","sizeBytes":10}""")) + } + + @Test + fun givenNoFileAtTheRecordedPath_whenTheDiskIsChecked_thenAbsentIsReported() { + val missing = folder.root.resolve("gone.gguf") + assertEquals(DiskStatus.ABSENT, DiskCheck.status(missing.absolutePath, 10)) + } + + @Test + fun givenADirectoryAtTheRecordedPath_whenTheDiskIsChecked_thenAbsentIsReported() { + val dir = folder.newFolder("not-a-file.gguf") + assertEquals(DiskStatus.ABSENT, DiskCheck.status(dir.absolutePath, 10)) + } + + @Test + fun givenATruncatedFile_whenTheDiskIsChecked_thenASizeMismatchIsReported() { + val file = folder.newFile("model.gguf").apply { writeBytes(ByteArray(64)) } + assertEquals(DiskStatus.SIZE_MISMATCH, DiskCheck.status(file.absolutePath, 128)) + } + + @Test + fun givenAFileOfTheVerifiedLength_whenTheDiskIsChecked_thenAMatchIsReported() { + val file = folder.newFile("model.gguf").apply { writeBytes(ByteArray(128)) } + assertEquals(DiskStatus.MATCHES, DiskCheck.status(file.absolutePath, 128)) + } +}