# Building from source

> Clone, build, install: JDK, SDK and build switch details.

URL: https://wmkeyboard.pages.dev/development/building/
Section: Development

WM Keyboard is an ordinary Gradle/AGP Android project. There's no bootstrap script to run first, and everything from the dictionary compiler to the F-Droid build goes through normal Gradle tasks. This page covers what you need installed, what the flavors differ on, and the things that catch people out the first time.

## Setting up your toolchain

1. **Install a JDK.** JDK 17 or newer is enough to launch the `gradlew` script. Gradle then runs on a JDK 21 toolchain, which it downloads from [Foojay](https://foojay.io/) on your first build, per `gradle/gradle-daemon-jvm.properties`. You do not have to install JDK 21 yourself.

2. **Install the Android SDK** with `compileSdk` 36.1 available. AGP asks for it as `release(36) { minorApiLevel = 1 }`, so platform 36 on its own is not enough. Android Studio's SDK Manager handles this, or use `sdkmanager` on the command line. `minSdk` is 24 (Android 7.0). `targetSdk` is 36.

3. **Clone the repo and build.**

   ```sh
   git clone <repo-url>
   cd WMKeyboard
   ./gradlew assembleIntlDebug
   ```

   The first run downloads the Gradle 9.5 distribution, the JDK 21 toolchain and every Maven dependency. Budget a few minutes and a good connection. Later builds are incremental.

4. **Install the APK.** Run `adb install app/build/outputs/apk/intl/debug/app-intl-debug.apk`. A default debug build is a single APK restricted to `arm64-v8a` by `ndk.abiFilters`. ABI-suffixed filenames like `app-intl-arm64-v8a-release.apk` only appear when `-Pwmkb.splitApks=true` turns on `splits.abi`, which is what the release workflow does.

5. **Turn the keyboard on.** Open the app. Follow the setup card to enable WM Keyboard as a system input method. See [installation](https://wmkeyboard.pages.dev/start/installation/) and [initial setup](https://wmkeyboard.pages.dev/start/setup/) for the in-app side of this.

The auto-provisioned JDK 21 toolchain is not the project's bytecode target. Every Android module compiles to Java 11 bytecode, with `sourceCompatibility`, `targetCompatibility` and Kotlin's `jvmTarget` all set to 11. The exception is `:tools:dictc`, a host-only JVM tool that never ships inside the APK and builds on a JDK 17 toolchain. JDK 21 is only what the Gradle daemon itself runs on.

## The F-Droid build

Up to 0.6.2 every module carried a `capabilities` flavor dimension, `full` and `lite`. That is gone: no library module has a product flavor, and the one build switch that changes which code compiles is a Gradle property, `wmkb.enableFdroid`.

- **Off** (every channel but F-Droid) builds every feature: ML Kit handwriting recognition, OCR (ML Kit, Tesseract and PaddleOCR), ML Kit QR/document scanning, the local LLM tool, offline Whisper on LiteRT, one-tap background removal in the sticker editor, and the rest.
- **On** (`-Pwmkb.enableFdroid=true`, which F-Droid's recipe sets) leaves out Google's SDKs: ML Kit, LiteRT, LiteRT-LM and the Google Home APIs, none of which F-Droid accepts. Harper, whisper.cpp, ExecuTorch and the other open-source engines stay. PaddleOCR stays too: without LiteRT it runs on MNN, the library the sticker generator already brings (`PaddleOcrModel.runtime`), from an MNN export of the same models (the data repository's `scripts/export_ppocrv5_mnn.py`).

Most code is the same either way and asks at runtime whether its library is there (`FeatureModules`, `GatedLibrary`, and on F-Droid a `NotInBuild` gate for each Google runtime). The few classes that call ML Kit directly live in `src/google/` in `:core:content`, `:core:intelligence` and `:feature:ime`, each with a stand-in of the same shape in `src/nogoogle/`, and `:app`'s `src/google/` holds the application class that starts WorkManager for ML Kit's downloads; the module's build file adds one or the other, the same way `:app` picks `src/gms` or `src/nogms`. `core/config` turns the switch into `BuildConfig` booleans (`GOOGLE_SDKS` and the `ENABLE_*` flags) so library modules can read them without depending on `:app`.

## The languages dimension

`:app` carries a second flavour dimension that the library modules do not. `intl` packages the app's own text in 48 languages besides English; `en` packages English alone. So `:app`'s variants are `intlDebug` and `enDebug`, while every `core/*` and `feature/*` module is plain `debug`.

Nothing about the Kotlin differs between `intl` and `en`. The whole of it is one line in `app/build.gradle.kts` that sets `localeFilters` on the `en` variants, which tells AGP to keep the unqualified `res/values` and drop every `res/values-xx` beside it, the dependencies' translations included. That is worth about 41 MB, because `resources.arsc` is stored uncompressed and a single APK carries every language it offers.

It has to be applied through the Variant API rather than in a `productFlavors` block: AGP 9 removed `resourceConfigurations` from `ProductFlavor`, and the DSL's `androidResources.localeFilters` is a single global setting that cannot vary per flavour.

One old name survives in `:app` as an alias: `assembleLiteRelease` builds `assembleIntlRelease`, which is what F-Droid takes, and mirrors the APK to the old output path. [Releasing](https://wmkeyboard.pages.dev/development/releasing/#getting-into-f-droid) has the reason, which is F-Droid's.

The practical consequence is that the two halves of the project disagree about variant names. `./gradlew testDebugUnitTest` runs the library suites and silently skips all of `:app`, which has no task by that name. Use `./gradlew unitTests`, the aggregate in the root build, which depends on both spellings.

See [Which build you have](https://wmkeyboard.pages.dev/start/editions/) for what this means from the user's side, and [architecture](https://wmkeyboard.pages.dev/development/architecture/) for how the module graph is laid out.

## A build with no internet permission

The released APKs declare `android.permission.INTERNET`, because the online tools, the downloads and the backups need it. If you want a keyboard that cannot reach the network at all, build one yourself with a single property:

```bash
./gradlew assembleEnDebug -Pwmkb.noInternet=true
```

It works on any variant, and `wmkb.noInternet=true` in `local.properties` or `WMKB_NO_INTERNET=true` in the environment does the same. The property adds `app/src/nointernet/AndroidManifest.xml`, which removes the permission from the merged manifest. Deleting the line from `app/src/main/AndroidManifest.xml` is not enough on the full edition: ML Kit declares the permission in its own manifest, and the merge puts it back. Check the result with `aapt dump permissions` on the APK.

Nothing else changes. Typing, prediction, autocorrect, gesture typing, learning, the clipboard, the local tools and the on-device models you already have keep working. Every feature that fetches something fails the way it does with the phone offline, and the tools that show an error say that this build cannot connect to the internet. What those features would have downloaded can be imported from files fetched elsewhere instead: language packs, voice models, OCR data, AI models and GIF packs. See [Using it without internet](https://wmkeyboard.pages.dev/privacy/offline/). KDE Connect stays off, because its connections need the same permission even on your own network.

Native delivery stays on. The feature libraries are pinned in `assets/native/pins.tsv` and left out of the APK as in the standard build, and every gate that pins one starts out missing with `NativeConsent.IMPORT` as its reason: the screens then list the exact files and offer the import instead of a download (`NativeDelivery.import` for a `.so`, `NativeDelivery.importArchive` for a vendor's `.aar`). The `en` flavour keeps `LANGUAGE_PACKS` on, so it takes the release's language packs as imports on Android 11 and later. `-Pwmkb.nativeDelivery=false` bundles every library again, at about ten times the size.

Each GitHub release carries two ready-made: **offline** (`wmkeyboard-<version>-vc<code>-full-en-nointernet-<abi>.apk`, `arm64-v8a` and `universal`) and **offline, all languages** (`…-full-intl-nointernet-universal.apk`), for Android 7 to 10, which can't load a language pack. Both install over the regular builds and back without losing a setting, so you can set the keyboard up and download what you need on a regular build, then install the no-internet one over it. Any other combination you build yourself.

In the code, every request passes through `NetLog` (or, for the OkHttp clients, the `InternetGate` interceptor) before its socket opens, and both refuse with an `IOException` when the permission is missing. That matters because Android does not fail politely without it: a host name lookup throws `SecurityException`, which the offline paths are not written to catch. `NetworkCallSitesTest` fails the build if an OkHttp client is added without the gate.

## A build with no location permission

The released APKs declare precise and approximate location for the [Location tool](https://wmkeyboard.pages.dev/tools/location/) and the **Use my location** buttons. A build without them takes one property, the same way:

```bash
./gradlew assembleEnDebug -Pwmkb.noLocation=true
```

`wmkb.noLocation=true` in `local.properties` or `WMKB_NO_LOCATION=true` in the environment does the same. The property adds `app/src/nolocation/AndroidManifest.xml`, which removes both permissions from the merged manifest. The app checks what the installed package asked for at run time (`LocationAccess.declared`), so the Location tool keeps its saved places and hides **Locate me**, the weather and compass tools go back to a searched or typed place, and the permissions screen drops the row. It combines with `wmkb.noInternet`.

## Rebuilding the text engines

Both editions ship the text engines' native library, and nothing types Bengali, Hindi, Urdu or the Indic languages without it.

The phonetic engines (Avro, Khipro, Hindi, Urdu, Arabic, Persian, Assamese and the eight Indic languages), the Bijoy ANSI converter, the Bengali grapheme helpers and the romanizers are one Rust crate, `native/wm-engine`, shared with WMK Desktop. `native/wm-engine-jni` wraps it in a JNI `cdylib`, and the Kotlin objects in `core/language`'s transliteration package (`AvroPhonetic`, `Khipro`, `BijoyAnsi` and the rest) are facades over it through `EngineNative`. The prebuilt `.so` files are committed under `core/language/src/main/jniLibs/<abi>/`, so a normal build never touches Rust or the NDK. Rebuild them after any change under `native/wm-engine`, `native/wm-engine-jni` or `app/src/main/assets/avro` (the Avro grammar files are compiled into the crate):

```sh
# One-time setup, as for the grammar engine below
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
cargo install cargo-ndk

# All three ABIs, into core/language/src/main/jniLibs/
ANDROID_NDK=/opt/homebrew/share/android-ndk native/wm-engine-jni/build.sh
```

The JVM unit tests load a host build of the same library instead, which the Gradle test tasks of `:core:language` and `:app` make first through `native/wm-engine-jni/build-host.sh` (plain `cargo build --release`). That is the one place the Gradle build needs Rust: `cargo` on the PATH, or in `~/.cargo/bin`. The engines' own tests run with `cargo test` in `native/wm-engine`; see [testing](https://wmkeyboard.pages.dev/development/testing/).

The same library is where prediction is moving and where the SIMD kernels live (see [architecture](https://wmkeyboard.pages.dev/development/architecture/)). Kernels use the stable `core::arch::aarch64` NEON intrinsics, so the pinned toolchain needs no nightly features. On arm64 the NEON paths run on the host too: an Apple silicon Mac checks them with `cargo test`, which compares each one against its scalar reference.

## Rebuilding the native grammar engine

Both editions ship the grammar checker's native library.

The grammar tool's linter is a Rust `cdylib` (`native/harper-jni/`) that wraps [Harper](https://github.com/automattic/harper)'s `harper-core` behind a small JNI surface. `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/grammar/HarperNative.kt` is the Kotlin entry point. The prebuilt `.so` files are committed under `core/intelligence/src/main/jniLibs/<abi>/`, so a normal build never touches Rust or the NDK. Rebuild the library only if you edit `native/harper-jni/src/lib.rs` or bump the `harper-core` version:

```sh
# One-time setup
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
cargo install cargo-ndk
brew install --cask android-ndk   # or any NDK; set ANDROID_NDK_HOME

# Build all three ABIs, from native/harper-jni/
ANDROID_NDK_HOME=/opt/homebrew/share/android-ndk \
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 --platform 24 \
  -o ../../core/intelligence/src/main/jniLibs build --release
```

The same command is in `native/harper-jni/README.md`, along with the JNI contract the Kotlin side depends on.

`--platform 24` matches the app's `minSdk` exactly. Release builds package all three supported ABIs (`arm64-v8a`, `armeabi-v7a` and `x86_64`) through `ndk.abiFilters` in `app/build.gradle.kts`. Debug builds restrict to `arm64-v8a` so local compiles finish sooner. Generic 32-bit `x86` is not supported.

## Rebuilding the text scanner's Tesseract library

google: unlike the grammar engine, F-Droid does not build it from source, so its build goes without.

The text scanner reads most non-Latin scripts with [Tesseract](https://github.com/tesseract-ocr/tesseract), compiled with Leptonica into one small library, `libwmtess.so`, from `native/tesseract-jni/`. `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/ocr/TesseractOcr.kt` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/intelligence/src/tesseract/jniLibs/<abi>/`, so a normal build needs no NDK here either. Rebuild them only after you edit `native/tesseract-jni/`:

```sh
# Needs NDK r29, CMake 3.22+ and Ninja
ANDROID_NDK=/opt/homebrew/share/android-ndk native/tesseract-jni/build.sh
```

The script fetches the pinned Tesseract4Android sources, builds all three ABIs and copies the stripped libraries into place. The **Tesseract native build** workflow on GitHub does the same and uploads the result. `native/tesseract-jni/README.md` has the JNI contract and the size measurements behind each build flag.

## Rebuilding the Hunspell library

Spell checking against an imported Hunspell `.aff` and `.dic` runs on upstream [Hunspell](https://github.com/hunspell/hunspell) 1.7.2, built with a small JNI bridge into `libwmhunspell.so` from `native/hunspell-jni/`. `core/prediction/src/main/java/com/wasimaster/wmkeyboard/core/prediction/HunspellNative.kt` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/prediction/src/main/jniLibs/<abi>/`. Every edition has it: GitHub builds pin it for download (gate `spell`), Play builds ship it in the on-demand `:feature:spell` split, and F-Droid builds it from source through `native/build-fdroid-libs.sh`. Rebuild it only after you edit `native/hunspell-jni/`:

```sh
# Needs CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/hunspell-jni/build.sh
```

The script fetches Hunspell at its pinned commit, builds all three ABIs in about ten seconds and copies the stripped libraries into place. `native/hunspell-jni/README.md` has the JNI contract. Upstream Hunspell rather than nuspell: nuspell needs ICU4C, which the NDK only exposes from API 31.

## Rebuilding the divvunspell library

GiellaLT spellers (`.bhfst`) run on [divvunspell](https://github.com/divvun/divvunspell), the Rust library Divvun's keyboards use, wrapped in a small JNI crate under `native/divvunspell-jni/` that builds `libwmdivvun.so`. `core/prediction/src/main/java/com/wasimaster/wmkeyboard/core/prediction/DivvunNative.kt` is the Kotlin entry point, and the prebuilt `.so` files sit beside Hunspell's under `core/prediction/src/main/jniLibs/<abi>/`. They share Hunspell's `spell` gate and `:feature:spell` split, and F-Droid builds them from source through `native/build-fdroid-libs.sh`. Rebuild only after you edit `native/divvunspell-jni/`:

```sh
# Needs rustup; the script installs Rust 1.99.1 and cargo-ndk 4.1.2
ANDROID_NDK=/opt/homebrew/share/android-ndk native/divvunspell-jni/build.sh
```

divvunspell is a git dependency pinned to one commit in `Cargo.toml` and `Cargo.lock`, because crates.io's last release is from 2023. A cold build of the three ABIs takes about 25 minutes, nearly all of it link-time optimisation. `native/divvunspell-jni/README.md` has the JNI contract and a host check (`cargo run --release --example probe -- <archive> <words>`) that runs the same code on a Mac against real archives.

## Rebuilding the GiellaLT grammar library

GiellaLT's grammar checkers (`.drb` bundles) run on [divvun-runtime](https://github.com/divvun/divvun-runtime), wrapped in a JNI crate under `native/divvungrammar-jni/` that builds `libwmdivvungrammar.so` with only the modules a grammar pipeline uses (HFST tokenising, VISL CG-3 and the speller and suggestion steps). `DivvunGrammarNative.kt` in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/grammar/` is the Kotlin entry point, the prebuilt files sit under `core/intelligence/src/divvungrammar/jniLibs/<abi>/`, and the gate is `divvungrammar`. The library is GPL-3.0, because divvun-runtime's VISL CG-3 port is; nothing else in the app links it.

```sh
# Needs rustup; the script installs Rust 1.99.1 and cargo-ndk 4.1.2
ANDROID_NDK=/opt/homebrew/share/android-ndk native/divvungrammar-jni/build.sh
```

`Cargo.lock` starts from divvun-runtime's own, so its git dependencies resolve to the commits it was tested with. `cargo run --release --example probe -- <bundle.drb> "<text>"` checks a text against a real bundle on a desktop.

## Rebuilding the CLD3 library

Language identification on the builds without ML Kit runs on Google's [CLD3](https://github.com/google/cld3) with a static protobuf-lite, built into `libwmcld3.so` from `native/cld3-jni/`. `LanguageId.kt` in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/translate/` is the Kotlin entry point; the gate is `langid`, which also carries fastText (below).

```sh
# Needs git, CMake, Ninja and a host C++ compiler; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/cld3-jni/build.sh
```

The script fetches CLD3 and protobuf 3.21.12 at pinned commits, builds protoc from the same protobuf for the machine it runs on, generates CLD3's three protos with it, and builds the three ABIs.

## Rebuilding the fastText library

The same gate's first identifier is [fastText](https://github.com/facebookresearch/fastText)'s `lid.176` model (176 languages, CC BY-SA 3.0), built into `libwmfasttext.so` from `native/fasttext-jni/` with the 938 KB quantised model compiled into the library (`model.S`), so the gate brings it and nothing is read from storage. `LanguageId` asks CLD3 only when fastText's top answer is under 0.5.

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/fasttext-jni/build.sh
```

The script fetches fastText at a pinned commit (the repository is archived) and the model from Meta's download server, checks the model's SHA-256, and builds the three ABIs. `FASTTEXT_SRC` and `LID_MODEL` point it at local copies instead.

## Rebuilding the Voikko library

Finnish spell checking runs on [libvoikko](https://github.com/voikko/corevoikko) 4.3.3, VFST backend only, built with a small JNI bridge into `libwmvoikko.so` from `native/voikko-jni/`. `core/prediction/src/main/java/com/wasimaster/wmkeyboard/core/prediction/VoikkoNative.kt` is the Kotlin entry point. The prebuilt `.so` files sit in `native/voikko-jni/jniLibs/<abi>/`, a directory no module packages, so that libvoikko stays in its own GPL-licensed source tree and out of every APK: GitHub builds pin it for download with Hunspell's under the `spell` gate, Play builds add it to the `:feature:spell` split, F-Droid builds it from source through `native/build-fdroid-libs.sh`, and the offline build pins it like the GitHub build and takes it as an import. Rebuild only after you edit `native/voikko-jni/`:

```sh
# Needs CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/voikko-jni/build.sh
```

The script fetches corevoikko at the `rel-libvoikko-4.3.3` tag and builds the three ABIs in about 20 seconds with CMake, standing in for libvoikko's autotools build. `build.sh --host <dir>` builds the same library for the Mac or Linux machine instead, to test the JNI from a desktop JVM. The Finnish dictionary the app downloads is built from the signed voikko-fi 2.6 source release by `native/voikko-jni/build-dict.sh <out-dir>`, which needs foma (`brew install foma`) and gives the same zip every time. `native/voikko-jni/README.md` has the JNI contract, the licence reasoning and the desktop check.

## Rebuilding the Bergamot library

On-device translation without Google runs on Mozilla's [bergamot-translator](https://github.com/mozilla/translations/tree/main/inference), the engine inside Firefox Translations, built with a small JNI bridge into `libwmbergamot.so` from `native/bergamot-jni/`. `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/translate/BergamotNative.kt` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/intelligence/src/bergamot/jniLibs/<abi>/`, a directory of their own so that the Play split carries nothing else. GitHub builds pin it for download (gate `bergamot`), Play builds ship it in the on-demand `:feature:bergamot` split, and F-Droid builds it from source through `native/build-fdroid-libs.sh`. Rebuild it only after you edit `native/bergamot-jni/` or move the pins in `build.sh`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/bergamot-jni/build.sh
```

The script fetches the revision of `mozilla/translations` that Firefox ships (only `inference/` and the five submodules the CPU library compiles), PCRE2 and zstd at pinned commits, and builds arm64-v8a and x86_64 for Android 9 (API 28), which Marian's path library needs. ARM runs Marian's integer maths on Google's ruy, the way Firefox's own ARM builds do; x86_64 uses intgemm's kernels, chosen at run time. armeabi-v7a gets `stub.c`, a library that refuses to load: Marian's NEON code needs AArch64, and Play wants the split to cover all three ABIs. A full build takes several minutes per engine ABI.

The model list is generated: `native/bergamot-jni/catalog.py` reads Mozilla's public Remote Settings collection `translations-models-v2` and writes `BergamotCatalog.kt` with each file's address, size and SHA-256. Run it to move to Mozilla's newer models, then review the diff. `native/bergamot-jni/README.md` has the JNI contract and the reasons behind each build flag.

## Rebuilding the CTranslate2 library

The NLLB-200 translation engine runs on OpenNMT's [CTranslate2](https://github.com/OpenNMT/CTranslate2) with Google's [SentencePiece](https://github.com/google/sentencepiece) for tokens, built with a small JNI bridge into `libwmct2.so` from `native/ctranslate2-jni/`. `Ct2Native.kt` beside `BergamotNative.kt` is the Kotlin entry point, and `NllbTranslator.kt` drives it. The prebuilt `.so` files sit under `core/intelligence/src/ct2/jniLibs/<abi>/`, a directory of their own for the Play split. GitHub builds pin it for download (gate `ct2`), Play builds ship it in the on-demand `:feature:ct2` split, and F-Droid builds it from source through `native/build-fdroid-libs.sh`. Rebuild it only after you edit `native/ctranslate2-jni/` or move the pins in `build.sh`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/ctranslate2-jni/build.sh
```

The script fetches CTranslate2 and SentencePiece at pinned tags, with CTranslate2's ruy, spdlog and cpu_features submodules and the abseil tag SentencePiece's own CMake would fetch, so configuring needs no network. Every matrix product runs on ruy, int8 and float alike, with no BLAS and no OpenMP; ruy picks the dot-product kernels on the phones that have them. arm64-v8a and x86_64 build for Android 7 (API 24). armeabi-v7a gets `stub.c`, since 600 MB of weights is more than a 32-bit process can count on. A build takes a few minutes per ABI.

The model is not in the library. `NllbTranslator.FILES` pins the four files of one Hugging Face revision by size and SHA-256. `native/ctranslate2-jni/README.md` has the JNI contract, the desktop test and the measurements behind the memory floor.

## Rebuilding the zinnia library

Chinese and Japanese handwriting without ML Kit runs on [zinnia](https://github.com/taku910/zinnia), built with a small JNI bridge into `libwmzinnia.so` from `native/zinnia-jni/`. `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/handwriting/ZinniaNative.kt` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/intelligence/src/zinnia/jniLibs/<abi>/`, a directory of their own so that the Play split carries nothing else. GitHub builds pin it for download (gate `zinnia`), Play builds ship it in the on-demand `:feature:zinnia` split, and F-Droid builds it from source through `native/build-fdroid-libs.sh`. Rebuild it only after you edit `native/zinnia-jni/` or move the pinned commit in `build.sh`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/zinnia-jni/build.sh
```

The script fetches zinnia at its pinned commit, compiles the recogniser's four sources for all three ABIs in about fifteen seconds and copies the stripped libraries into place. `build.sh --desktop DIR` builds the same library for the machine it runs on, for testing the JNI from a plain JVM. The models are not part of the build: the Tegaki project's three, pinned in `core/input/.../zinnia/ZinniaData.kt`, download from the data repository as CJK packs. `native/zinnia-jni/README.md` has the JNI contract, where each model came from, and the desktop test's numbers.

## Rebuilding the llama.cpp library

GGUF models for on-device AI run on [llama.cpp](https://github.com/ggml-org/llama.cpp), built with its chat-template code and a small JNI bridge into `libwmllama.so` (and `libwmllama_v82.so` on arm64) from `native/llamacpp-jni/`. `LlamaNative.kt` in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/localllm/` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/intelligence/src/llama/jniLibs/<abi>/`, a directory of their own so the Play split carries nothing else. GitHub builds pin them for download (gate `llama`), Play builds ship them in the on-demand `:feature:llama` split, and F-Droid builds them from source through `native/build-fdroid-libs.sh`. Rebuild after you edit `native/llamacpp-jni/` or move the pin in `pin.sh`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/llamacpp-jni/build.sh
```

The script fetches llama.cpp at the release `pin.sh` names, with the KleidiAI release its ggml names (checked against the hash in `pin.sh`, so configuring downloads nothing), and builds the CPU libraries: arm64 three times (ARMv8.0; ARMv8.2 with dot product and fp16; and ARMv8.2 with i8mm, the int8 matrix instructions of 2022 and later cores, picked from `/proc/cpuinfo` at load the way whisper.cpp's are), armeabi-v7a and x86_64. Every arm64 build carries Arm's KleidiAI kernels, which choose themselves at run time from the CPU's features. Then come the GPU builds on arm64: OpenCL for Adreno (gate `llamagpu`) and Vulkan for every other GPU (gate `llamavulkan`). The Vulkan build compiles its shaders with a glslc that `native/vulkan.sh` builds from a pinned shaderc release, since the NDK's is too old for the extensions ggml's newer shaders use; whisper.cpp's build shares the same file. `WMKB_LLAMA_CPU=0`, `WMKB_LLAMA_GPU=0` and `WMKB_LLAMA_VULKAN=0` skip parts. A Hexagon NPU build needs Qualcomm's Hexagon SDK, which runs only on x86-64 Linux, so it is built by the `llama.cpp Hexagon build` workflow (`.github/workflows/llama-hexagon.yml`) in Qualcomm's toolchain container, run by hand, and its artifact committed into `core/intelligence/src/llamanpu/jniLibs/` (gate `llamanpu`). Its library and DSP skels, one per Hexagon generation from v73 to v85, linked at 16 KB like every library in `lib/arm64-v8a`, are never part of F-Droid's build. The DSP loads its skel off the filesystem through `ADSP_LIBRARY_PATH`, so `LlamaHexagon` copies this chip's out of the APK when Play left it inside. It keeps its own copy of ggml rather than sharing whisper.cpp's: the two projects move ggml at different times, and the two libraries come through different gates. `native/llamacpp-jni/host-test.sh` builds the same bridge for the Mac or Linux, so `LlamaNative` can be run on a desktop JVM; the README has the contract, the harness recipe and the measured speeds.

## Rebuilding the document scanner library

The built-in document scanner, which the scanner tool opens where Google's is missing or switched off, runs on [OpenCV](https://github.com/opencv/opencv) 5.0.0: only its `core`, `imgproc`, `flann` and `geometry` modules, built static, with a small JNI bridge and the scanner's own page detection, flattening and clean-up (`docscan.cpp`) into `libwmdocscan.so` from `native/docscan-jni/`. `DocScanNative.kt` in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/docscan/` is the Kotlin entry point, and the prebuilt `.so` files sit under `core/intelligence/src/docscan/jniLibs/<abi>/`, a directory of their own so the Play split carries nothing else. GitHub builds pin them for download (gate `docscan`), Play builds ship them in the on-demand `:feature:docscan` split, and F-Droid builds them from source through `native/build-fdroid-libs.sh`. Rebuild after you edit `native/docscan-jni/`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/docscan-jni/build.sh
```

The script fetches OpenCV at its pinned commit and builds the three ABIs one after another, deleting each build tree before the next. `native/docscan-jni/opencv.cmake` holds the OpenCV options: no IPP or any other accelerator that downloads a binary at configure time. The arm64 build adds Arm's KleidiCV as OpenCV's HAL, from a release archive `build.sh` fetches and checks against its pinned hash. To try detection and the clean-up modes on photos without a phone, `native/docscan-jni/desktop/check.sh <photos> <out>` builds the same `docscan.cpp` against a host OpenCV and writes each photo's outline, flattened page and filtered versions; `desktop/make_samples.py` makes synthetic test photos with known corners.

The QR scanner's open-source reader, used where ML Kit's barcode reader can't run or isn't picked, is [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) v3.1.1 with its readers only (no writers, so none of its bundled libzint), built static under a small JNI bridge into `libwmzxing.so` from `native/zxingcpp-jni/`. `ZxingNative.kt` in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/barcode/` is the Kotlin entry point, the prebuilt files sit under `core/intelligence/src/zxing/jniLibs/<abi>/`, and the gate is `zxing` in `native/gates.json`: pinned for download on GitHub, the on-demand `:feature:zxing` split on Play, built from source on F-Droid. Rebuild after you edit `native/zxingcpp-jni/`:

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/zxingcpp-jni/build.sh
```

`build.sh --desktop DIR` builds the same library for the Mac, for checking the bridge in a plain JVM against zxing-cpp's own sample images; `native/zxingcpp-jni/README.md` has the recipe and the last results.

## Rebuilding the sticker generator's libraries

The [sticker generator](https://wmkeyboard.pages.dev/emoji/sticker-generator/) runs on two native libraries. `libwmimagegen.so`, from `native/imagegen-jni/`, is [MNN](https://github.com/alibaba/MNN) 3.6.1 built static with its CPU backend (and on arm64 its OpenCL and Vulkan backends, which open the phone's own `libOpenCL.so` or `libvulkan.so` at run time for the sticker generator's **Draw on the GPU**), under a small JNI bridge (`imagegen.cpp`) that loads one `.mnn` module per handle and moves tensors in and out; the denoising loop, the schedulers and the CLIP tokenizer are Kotlin, in `core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/imagegen/`. The scanner's PaddleOCR reader runs its two graphs through the same library in builds without LiteRT (`MnnPaddleRuntime`), so the app carries one MNN, not two. Its prebuilt files sit under `core/intelligence/src/imagegen/jniLibs/<abi>/`. GitHub builds pin them for download (gate `imagegen` in `native/gates.json`), Play builds ship them in the on-demand `:feature:imagegen` split, and F-Droid builds them from source through `native/build-fdroid-libs.sh`.

```sh
# Needs git, CMake and Ninja; NDK r29 reproduces the committed bytes
ANDROID_NDK=/opt/homebrew/share/android-ndk native/imagegen-jni/build.sh
```

On arm64 it builds in Arm's KleidiAI matrix kernels, which MNN picks among at run time by the CPU's features; `build.sh` fetches the KleidiAI release MNN expects and checks its hash, so configuring downloads nothing. It leaves out float16 kernels (never used: MNN's float16 path gives a wrong UNet output) and transformer fusion (it changes what a one-step model draws). `native/imagegen-jni/desktop/check.sh <bundle>` builds the same `imagegen.cpp` for the computer and compares each network's output with MNN's Python bindings on one model bundle.

`libwmimagegen_qnn.so`, from `native/imagegen-qnn/`, runs Qualcomm AI Hub's Stable Diffusion context binaries on the Snapdragon NPU. It links nothing of Qualcomm's: it opens `libQnnHtp.so` and `libQnnSystem.so` by path from the directory the app's NPU runtime unpacks them into. It is compiled against the QNN API headers, which carry Qualcomm's notice, so `build.sh` fetches them from Qualcomm's own `geniex-qairt-plugin` repository at a pinned commit and never commits them; the library itself is arm64 only, sits under `core/intelligence/src/imagegennpu/jniLibs/`, rides the `imagegen` gate and split, and is left out of the F-Droid build with the rest of the NPU runtime.

```sh
ANDROID_NDK=/opt/homebrew/share/android-ndk native/imagegen-qnn/build.sh
```

`libwmexecutorch_qnn.so` is ExecuTorch built a second time on arm64 with its Qualcomm delegate beside XNNPACK, so a program exported for the Snapdragon NPU runs its delegated parts there. `native/executorch-jni/build.sh` builds it after the plain library unless `WMKB_ET_QNN=0` (which `build-fdroid-libs.sh` sets). The delegate needs more of the QNN headers than `geniex-qairt-plugin` carries, so `qnn_headers.py` reads `include/QNN` out of the QAIRT SDK zip ExecuTorch pins, by range requests rather than downloading all 1.35 GB, and checks the extracted tree against a pinned digest. The delegate is built static, so it registers with the same runtime the JNI code calls, and it loads Qualcomm's libraries from the NPU runtime's directory at run time. It sits under `core/voice/src/executorchqnn/jniLibs/` and is its own gate, `executorchqnn`.

Two more arm64 variants come out of the same script, each its own gate. `libwmexecutorch_vulkan.so` (`executorchvulkan`, `WMKB_ET_VULKAN=0` skips it) adds ExecuTorch's Vulkan GPU delegate, whose GLSL shaders are compiled into the library with the glslc `native/vulkan.sh` builds (or the one `GLSLC` names); it opens the phone's `libvulkan.so` at run time and needs Vulkan 1.1. `libwmexecutorch_neuron.so` (`executorchneuron`, `WMKB_ET_NEURON=0` skips it, as F-Droid's build does) adds MediaTek's NeuroPilot delegate for the Dimensity 9300 and 9400. It opens the phone's own NeuroPilot adapter at run time and compiles against MediaTek's `NeuronAdapter.h`, which is not in ExecuTorch's repository but is in AOSP's copy of it, so the script fetches it from AOSP at a pinned commit, checks its hash and never commits it. `patch_neuron.py` builds that delegate static and makes its buffer allocator, a library from MediaTek's SDK that phones do not have, optional: ExecuTorch otherwise calls it from a static initialiser and would crash the process on load without it. `EtNative` loads the first variant whose gate is on the phone and whose hardware the phone has, QNN before NeuroPilot before Vulkan, and the plain library otherwise. None of the keyboard's models is exported for these delegates yet; a program that is runs on whichever variant carries its delegate.

`tools/imagegen/convert_sd.py` turns a Stable Diffusion 1.x or 2.x checkpoint into the bundle the app imports; see [Importing a model](https://wmkeyboard.pages.dev/emoji/sticker-generator/#importing-a-model).

## Adding a native library

Each library above belongs to a gate in `native/gates.json`, which records where its `.so` files are committed, which script builds them for F-Droid and which classes R8 must keep. The Gradle build and the release scripts read that file and keep no list of their own, so a new library or a new gate is one entry there. The fields and the few files a new Play split still needs are in [architecture](https://wmkeyboard.pages.dev/development/architecture/#adding-a-native-gate). `python3 native/gates.py fdroid-scripts` prints the scripts `native/build-fdroid-libs.sh` will run, in order.

## Running static analysis

```sh
./gradlew staticAnalysis
```

That one task chains every analyzer the project uses: Android Lint (`lintIntlDebug`), plus type-resolved detekt for `:app` (its sources and its unit tests) and for nineteen library modules across `core/*` and `feature/*`. `core/config` is skipped because it has no Kotlin sources. `tools:dictc` is skipped because its build adds `:core:prediction`'s source directory as an extra Kotlin source directory, so analyzing it would report those same files a second time. F-Droid's stand-ins in `src/nogoogle` only compile with the switch on, so a bug in one is invisible to the plain run: `./gradlew staticAnalysis -Pwmkb.enableFdroid=true` analyses them.

detekt 1.23's Android integration doesn't register `detekt` tasks under AGP 9. A convention plugin (`wmkeyboard.detekt`) hand-registers them instead. Each module gets its own `detektDebug` task that analyzes only its own sources against its own compile classpath, which avoids the false positives you get from analyzing one module's code against another's classpath.

Both analyzers read curated configs rather than defaults. `config/detekt/detekt.yml` builds on detekt's default ruleset, not the more opinionated `allRules`. `config/lint/lint.xml` starts from every Lint check enabled, including the roughly 200 that are off by default, then silences the ones that don't apply to an IME, each with a written reason, and escalates crash-class issues to build-breaking errors. There's no baseline file anywhere in the repo, so `staticAnalysis` is expected to run clean. Treat a new finding as a real regression.

If you want the Kotlin compiler itself to fail the build on any warning (unused results, redundant casts, and the like), pass `-PwarningsAsErrors=true`:

```sh
./gradlew assemble -PwarningsAsErrors=true
```

This is off by default so a mid-refactor warning doesn't block a local build.

See [contributing](https://wmkeyboard.pages.dev/development/contributing/) for how static analysis fits into a pull request, and [testing](https://wmkeyboard.pages.dev/development/testing/) for running the unit test suite itself.

## Checking dependencies

Two checks look at the dependencies rather than the code.

**Licenses.** [Licensee](https://github.com/cashapp/licensee) checks every library the APK ships against the licenses the project allows, and fails the build on anything else:

```sh
./gradlew :app:licenseeAndroidIntlRelease     # any variant works
```

The allow list in `app/build.gradle.kts` is Apache-2.0, MIT, the two BSD licenses and ISC. Three libraries whose POMs name their license by URL are allowed one by one, each with the reason written next to it. Google's own terms (ML Kit, Play services, Play Core) are allowed only outside the F-Droid channel, so a Google binary that slips into an F-Droid build fails there, before F-Droid's own scanner finds it. A new dependency under any other license stops the build until someone has read the license and added it, with a reason. Each run writes an inventory of every artifact and its license to `app/build/reports/licensee/android/artifacts.json`. CI runs the check for the standard build and for the build as F-Droid makes it.

**Unused and mis-scoped dependencies.** The [Dependency Analysis plugin](https://github.com/autonomousapps/dependency-analysis-gradle-plugin) reads which dependencies each module's code actually uses:

```sh
./gradlew buildHealth     # build/reports/dependency-analysis/build-health-report.txt
```

It reports what could be removed, what is declared `api` but only used internally (or the other way round), and what the code uses but only gets through another module. It gives advice and passes; nothing here gates on it. It reads one variant per module, `intlDebug` in `:app` and `debug` elsewhere, which `gradle.properties` sets. Every other variant is the same code against the same dependencies. Treat its advice as a starting point: a library reached only through reflection, a manifest entry or a `runtimeOnly` need looks unused to it.

## Details & edge cases

- **No `local.properties` is required to build.** API keys for the network tools (GIF/sticker search, web search, translation, and the Unsplash/Pexels photo backgrounds) are read from `local.properties`, falling back to environment variables, falling back to an empty string. The Dropbox and OneDrive backup destinations read their OAuth client ids the same way. A missing key never fails the build. The affected tool shows a "needs API key" panel at runtime, and a backup destination with no client id is left out of the list instead.
- **A missing release keystore doesn't fail a release build, but it doesn't sign it either.** If `RELEASE_STORE_FILE` isn't set or the file doesn't exist, `assembleIntlRelease` drops the signing config and emits an *unsigned* release APK. There's no debug-key fallback, and that's deliberate: a debug-signed "release" looks shippable and isn't, because Play rejects the debug key and nothing installed from such a build could ever be updated by the real one.
- **Module build files can't declare their own repositories.** `settings.gradle.kts` sets `repositoriesMode = FAIL_ON_PROJECT_REPOS`, so only the `google()`/`mavenCentral()` repositories declared centrally are allowed. Adding a `repositories { }` block inside any module's `build.gradle.kts` hard-fails the build rather than silently working.
- **Dictionaries compile automatically.** Every build variant wires in a `compileBundledDictionaries` task that runs the `:tools:dictc` compiler over `dictionaries-src/*.txt` and produces the `.wmdict` assets bundled into the APK. There's no separate manual compilation step. See [the dictionary pipeline](https://wmkeyboard.pages.dev/development/dictionaries/) for the format and how to add or edit a wordlist.
- **CI runs on every pull request** (`.github/workflows/ci.yml`): unit tests and the architecture rules (`unitTests`), debug assembles of the standard and F-Droid builds, the dex method-size check, the license check, an advisory prediction/gesture eval run, and a docs link-check. `./gradlew staticAnalysis` is *not* in CI, so run it yourself before opening a PR. There's no pull-request or issue template.
