Skip to content
WM KeyboardWM Keyboard
Accessibility

Building from source

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

WM Keyboard is a standard Gradle/AGP Android project: there’s no special bootstrap script, and everything from the dictionary compiler to the two build flavors runs through ordinary Gradle tasks. This page covers what you need installed, what assembleFullDebug and assembleLiteDebug actually differ on, and the handful of things that trip people up the first time.

  1. Install a JDK. You need JDK 17 or newer just to launch the gradlew script. Gradle itself then runs on a JDK 21 toolchain that it downloads automatically via Foojay the first time you build, per gradle/gradle-daemon-jvm.properties. You don’t need to install JDK 21 yourself.

  2. Install the Android SDK with compileSdk 36 available (Android Studio’s SDK Manager handles this, or 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 assembleFullDebug

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

  4. Install it. adb install app/build/outputs/apk/full/debug/app-full-arm64-v8a-debug.apk: the arm64-v8a segment comes from the ABI split (splits.abi in app/build.gradle.kts), not from the version name. Then open the app and 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 19 :core:*/:feature:* modules (the standalone :tools:dictc compiler is a plain JVM module and doesn’t carry flavors at all):

  • assembleFullDebug — every feature: ML Kit handwriting recognition, ML Kit OCR/QR/document scanning, the Harper grammar checker, the local LLM tool, and offline Whisper dictation.
  • assembleLiteDebug — all five of those switched off, which drops roughly 100 MB of ML Kit models and the Harper native library from the install. Useful for low-storage devices.

Both flavors exist on every module so the project builds uniformly across variants, but only three modules actually carry different source code per flavor: core/voice, core/intelligence, and feature/ime each have a real src/full/ and src/lite/ split. Everywhere else the flavor dimension is structural only; the sources compiling under full and lite are identical. core/config mirrors the same five ENABLE_* flags as BuildConfig booleans so library modules can read them without depending on :app.

See full vs. lite for what this means from the user’s side, and architecture for how the module graph is laid out.

Full edition only: the grammar checker’s native library only ships in full builds.

The grammar tool’s linter is a Rust cdylib (native/harper-jni/) wrapping Harper’s harper-core behind a small JNI surface, with core/intelligence/src/main/java/com/wasimaster/wmkeyboard/core/grammar/HarperNative.kt as the Kotlin entry point. The prebuilt .so files are committed to the repo under core/intelligence/src/full/jniLibs/<abi>/, so a normal assembleFullDebug never touches Rust or the NDK at all. You only need to rebuild this if you’re editing native/harper-jni/src/lib.rs itself or bumping 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/full/jniLibs build --release

--platform 24 matches the app’s minSdk exactly. Release builds package all three supported ABIs (arm64-v8a, armeabi-v7a, and x86_64) via ndk.abiFilters in app/build.gradle.kts, while debug builds restrict to arm64-v8a for faster local compilation. Generic 32-bit x86 is not supported.

Terminal window
./gradlew staticAnalysis

This one task chains every analyzer the project uses: Android Lint (lintFullDebug), and type-resolved detekt for :app (both flavors, plus its unit tests) and for eighteen library modules across core/* and feature/*. core/config is skipped because it has no Kotlin sources, and tools:dictc is skipped because its source set is symlinked from :app and would just report the same files twice. Detekt additionally runs a separate lite pass for core/voice, core/intelligence, and feature/ime (the three modules with real full/lite source divergence), since a bug in a lite-only stub is invisible to the full-flavor run.

detekt 1.23’s Android integration doesn’t register detekt<Variant> tasks under AGP 9, so these are hand-registered by a convention plugin (wmkeyboard.detekt) rather than coming from the detekt Gradle plugin directly: each module gets its own detektFullDebug/detektLiteDebug task analyzing only its own sources against its own compile classpath, which avoids the false positives you’d get analyzing one module’s code against another’s classpath.

Both analyzers read from curated configs rather than defaults: config/detekt/detekt.yml builds on detekt’s default ruleset (not the more opinionated allRules), and 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; a new finding is a real regression, not baseline noise to suppress.

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 when static analysis is required before a PR merges, and testing for running the unit test suite itself.

  • No local.properties is required to build. API keys for the network tools (GIF/sticker search, web search, translation) are read from local.properties, falling back to environment variables, falling back to an empty string: a missing key never fails the build, the affected tool just shows a “needs API key” panel at runtime.
  • A missing release keystore doesn’t fail a release build either. If RELEASE_STORE_FILE isn’t set or the file doesn’t exist, assembleFullRelease silently signs with the debug keyconfig instead of failing, so you always get an installable APK even without release signing set up.
  • 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.
  • There’s no CI pipeline or contribution template in this repo yet. ./gradlew staticAnalysis and ./gradlew testFullDebugUnitTest are the same checks a human runs locally, so running them yourself before opening a PR is the closest thing to a gate that exists today.