Skip to content
WM KeyboardWM Keyboard
Accessibility

Building from source

Clone, build, install: JDK, SDK and flavor details.

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 two build flavors 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.

  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 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.

    Terminal window
    git clone <repo-url>
    cd WMKeyboard
    ./gradlew assembleFullIntlDebug

    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/fullIntl/debug/app-full-intl-debug.apk. A default debug build is a single APK restricted to arm64-v8a by ndk.abiFilters. ABI-suffixed filenames like app-full-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 and initial setup for the in-app side of this.

WM Keyboard ships as two build flavors on the same capabilities flavor dimension, declared identically in :app and all 20 :core:*/:feature:* modules. Two more, :feature:llm and :feature:translate, declare the same dimension but only join the build for Play-channel releases. The standalone :tools:dictc compiler is a plain JVM module and carries no flavors at all.

  • full builds every feature: ML Kit handwriting recognition, OCR (ML Kit and Tesseract), ML Kit QR/document scanning, the Harper grammar checker, the local LLM tool, offline Whisper dictation, and one-tap background removal in the sticker editor.
  • lite switches all of those off. That drops roughly 100 MB of ML Kit models and the Harper native library from the install, which is what makes it useful on a low-storage device.

Both flavors exist on every module so the project builds uniformly across variants. Four modules actually carry different Kotlin per flavor: core/voice, core/content, core/intelligence and feature/ime each have a real src/full/ and src/lite/ split. :app has a src/full/ as well, holding a manifest that swaps in the full edition’s application class, with nothing on the lite side. Everywhere else the flavor dimension is structural, and the sources compiling under full and lite are identical. core/config mirrors five ENABLE_* flags as BuildConfig booleans so library modules can read them without depending on :app. The sticker cutout has no flag of its own: core/content declares the segmentation library as fullImplementation and its lite stub reports supported = false.

: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 fullIntlDebug, fullEnDebug, liteIntlDebug and liteEnDebug, while every core/* and feature/* module is still fullDebug and liteDebug.

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.

Two single-dimension names survive in :app as aliases, one per store: bundleFullRelease builds bundleFullEnRelease, which is what Play takes, and assembleLiteRelease builds assembleLiteIntlRelease, which is what F-Droid takes. Each also mirrors its artifact to the old output path. Releasing has the reason, which is F-Droid’s. No other old name was kept, so assembleFullRelease and installFullDebug are still ambiguity errors.

The practical consequence is that the two halves of the project disagree about variant names. ./gradlew testFullDebugUnitTest 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 full vs. lite for what this means from the user’s side, and architecture for how the module graph is laid out.

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:

Terminal window
./gradlew assembleFullEnDebug -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. KDE Connect stays off, because its connections need the same permission even on your own network. Each GitHub release carries two ready-made, in arm64-v8a and universal: full · all languages (wmkeyboard-<version>-vc<code>-full-intl-nointernet-<abi>.apk) and the much smaller lite · English (…-lite-en-nointernet-<abi>.apk). Both install over the regular builds and back without losing a setting, so you can set the keyboard up and download your language packs 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.

The released APKs declare precise and approximate location for the Location tool and the Use my location buttons. A build without them takes one property, the same way:

Terminal window
./gradlew assembleFullEnDebug -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.

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):

Terminal window
# 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.

Both editions ship the grammar checker’s native library.

The grammar tool’s linter is a Rust cdylib (native/harper-jni/) that wraps 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:

Terminal window
# 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

Section titled “Rebuilding the text scanner’s Tesseract library”

Full edition only: unlike the grammar engine, it only ships in full builds.

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

Terminal window
# 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.

Terminal window
./gradlew staticAnalysis

That one task chains every analyzer the project uses: Android Lint (lintFullDebug), plus type-resolved detekt for :app (both flavors 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. Detekt also runs a separate lite pass for core/voice, core/intelligence and feature/ime, since a bug in a lite-only stub is invisible to the full-flavor run. core/content has a lite stub too and no lite pass, so add one there if that file grows.

detekt 1.23’s Android integration doesn’t register detekt<Variant> tasks under AGP 9. A convention plugin (wmkeyboard.detekt) hand-registers them instead. Each module gets its own detektFullDebug/detektLiteDebug 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:

Terminal window
./gradlew assemble -PwarningsAsErrors=true

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

See contributing for how static analysis fits into a pull request, and testing for running the unit test suite itself.

Two checks look at the dependencies rather than the code.

Licenses. Licensee checks every library the APK ships against the licenses the project allows, and fails the build on anything else:

Terminal window
./gradlew :app:licenseeAndroidFullIntlRelease # 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<Variant>/artifacts.json. CI runs the check for the full and lite builds, and for lite as F-Droid builds it.

Unused and mis-scoped dependencies. The Dependency Analysis plugin reads which dependencies each module’s code actually uses:

Terminal window
./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, fullIntlDebug in :app and fullDebug 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.

  • 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, assembleFullRelease 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 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), a debug assemble of every flavor, 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.