Skip to content
WM KeyboardWM Keyboard
Accessibility

Releasing

The three release channels, what each build flag does, and the exact steps for GitHub, F-Droid and Play.

WM Keyboard ships through three channels, and they are not the same build. The differences are real: a Play build carries Google libraries that an F-Droid build must not have. But a handful of boolean flags and the languages flavor drive all of it, so nothing here needs a branch or a patched source tree.

GitHub releaseF-DroidPlay Store
Flavorstandard: en; offline: en, and intl for its all-languages build (intl is also built to cut the language packs from)intlen
Gradle taskassembleEnRelease and assembleIntlReleaseassembleLiteRelease, an alias for assembleIntlReleasebundleEnRelease
wmkb.enablePlayStorefalsefalsetrue
wmkb.enableGmstruefalsetrue
wmkb.enableFdroidfalsetruefalse
wmkb.splitApkstruenot usedignored by bundle*
wmkb.enableCrashScreennot setnot setfalse, forced by the Fastfile over local.properties
Automation TYPE_TEXTofferedofferedleft out
Artifactthe standard build (full-en), one APK per ABI, plus one signed language pack per interface language (-lang-<locale>.apk), R8 mappings, native symbols, the all-languages build (full-intl), one APK per ABI, the baseline profile as dex metadata (*-api31.dm and *-api28.dm, which the in-app updater installs with the APK); the offline job adds full-en-nointernet in arm64-v8a and universal and full-intl-nointernet in universal. The feature libraries go to the data repository’s natives releaseone APK per ABI from 0.6.2, built by F-Droid, each carrying our signature once it matches the release’s -vc<code>-fdroid.apk for that ABIone AAB, with its R8 mapping and native symbols inside
Manifest versionCode (from 0.6.2)wmkb.versionCode × 10 plus 1 for armeabi-v7a, 2 for arm64-v8a, 3 for x86_64, 0 for universalthe same three digits, one APK each× 10, digit 0
ML Kitbundled in the APKabsentunbundled, fetched from Play services
Local LLMbundledabsenton-demand :feature:llm module
On-device translation (ML Kit)bundledabsenton-demand :feature:translate module
LiteRT interpreter (offline Whisper, the sticker editor’s own background remover)bundledabsenton-demand :feature:litert module
Handwriting recognition (ML Kit)bundledabsenton-demand :feature:handwriting module
Text engines (Avro, Khipro, Bijoy, Hindi, Urdu, the Indic languages)committed prebuilt librarycompiled from source by the recipethe same prebuilt library, in the base module
Harper, whisper.cpp, ExecuTorch (grammar, Offline Whisper, Asr4All, Parakeet, Moonshine, the FUTO Swipe glide engine)committed prebuilt librariescompiled from source by the recipethe same prebuilt libraries, in the on-demand :feature:speech (whisper.cpp), :feature:executorch (ExecuTorch) and :feature:grammar (Harper) modules
Hunspell (spell checking against an imported .aff/.dic)committed prebuilt library, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt library, in the on-demand :feature:spell module
divvunspell (GiellaLT spellers, beside Hunspell)committed prebuilt library, pinned and downloaded on demand with Hunspell’scompiled from source by the recipethe same prebuilt library, in the :feature:spell module with Hunspell’s
Voikko (Finnish spell checking)committed prebuilt library outside every module, pinned and downloaded on demand with Hunspell’s; never in the APKcompiled from source by the recipe, pinned the same waythe same prebuilt library, in the :feature:spell module with Hunspell’s
Bergamot (on-device translation without Google, 64-bit Android 9 and later; armeabi-v7a gets a stub that refuses to load)committed prebuilt library, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt library, in the on-demand :feature:bergamot module
CTranslate2 (the NLLB-200 translation engine, 64-bit phones; armeabi-v7a gets a stub that refuses to load)committed prebuilt library, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt library, in the on-demand :feature:ct2 module
llama.cpp (GGUF models for on-device AI)committed prebuilt libraries, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt libraries, in the on-demand :feature:llama module
zinnia (Chinese and Japanese handwriting without ML Kit)committed prebuilt library, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt library, in the on-demand :feature:zinnia module
OpenCV (the built-in document scanner)committed prebuilt library, pinned and downloaded on demandcompiled from source by the recipethe same prebuilt library, in the on-demand :feature:docscan module
MNN (the sticker generator) and its NPU runnercommitted prebuilt libraries, pinned and downloaded on demandMNN compiled from source by the recipe; no NPU runnerthe same prebuilt libraries, in the on-demand :feature:imagegen module
Whistle, Tesseract (Whistle dictation, the scanner’s offline reader)committed prebuilt libraries, full onlyabsentthe same prebuilt libraries, in the on-demand :feature:speech (Whistle, arm64 only) and :feature:ocr (Tesseract) modules
In-app updatesGitHub releases, downloads and installschecks F-Droid, links outPlay In-App Updates
Native libraries in the APKuncompressed and page-aligneduncompressed and page-aligneduncompressed and page-aligned; Play compresses the download itself
Bundled layoutsJSON compacted at build timesamesame

The flags read from a -P Gradle property first, then local.properties, then the environment, then default to false. -P winning is what lets a fastlane lane or CI override a developer machine’s local.properties. A clean checkout has no local.properties at all, which is why an F-Droid builder gets the right answer without being told.

wmkb.enableFdroid is the odd one out: it changes no dependency, only the channel line on bug reports and diagnostics, and it hides the “get it on F-Droid” row in About that would otherwise send an F-Droid user to their own install. The F-Droid recipe sets it explicitly, since false is the default everywhere else.

The channel picks source directories and a manifest overlay, wired through the Variant API in app/build.gradle.kts (channelSourceDirs and channelManifests). There is no product flavour for it: the channel is orthogonal to the others, and a dimension for it would double every variant and task name in the project for the sake of one file. The languages dimension was judged to be worth that price and the channel was not, because languages changes about 41 MB of what a user downloads.

DirectoryCompiled whenHolds
app/src/play/javaenablePlayStorePlayAppUpdater, and SplitInstall for :feature:llm and :feature:translate
app/src/noplay/javaotherwisethe no-op SplitInstall stub
app/src/github/javaneither flagthe release check, the download, and the package installer
app/src/fdroid/javaenableFdroidthe F-Droid index check
app/src/play/AndroidManifest.xmlenablePlayStorea <queries> entry for the Play Store, so the app can see who installed it
app/src/github/AndroidManifest.xmlneither flagREQUEST_INSTALL_PACKAGES, UPDATE_PACKAGES_WITHOUT_USER_ACTION, and the install-result receiver

Each of the three declares the same rememberAppUpdater(), against the AppUpdater interface in app/src/main. Everything worth unit testing (the asset-name parser, the release picker, the check interval, the install-status mapping) lives in src/main too, because src/test is compiled in every channel and a test that named a class from src/github/java would break check on a Play build.

To confirm the split holds, read the merged manifest rather than trusting the build file:

Terminal window
rm -rf app/build/intermediates/merged_manifest
./gradlew :app:processIntlDebugMainManifest -Pwmkb.enablePlayStore=true
grep -c REQUEST_INSTALL_PACKAGES \
app/build/intermediates/merged_manifest/intlDebug/processIntlDebugMainManifest/AndroidManifest.xml

That has to print 0 for a Play build and 1 for a GitHub one.

The Release workflow does the build. It runs on any tag starting with v and refuses to run if the tag disagrees with wmkb.versionName. A tag with no wmkb.versionName at all predates the property (v0.1.0 and v0.2.0 kept the version in app/build.gradle.kts), so the run stands down with a notice instead of comparing the tag against an empty string, and that release is written by hand. Neither of those two tags carries this workflow, so pushing them starts no run at all; the guard is for a tag that does reach it without a version to check. It signs with your release key, then attaches the standard build and the all-languages build (full-intl), one APK per ABI each, to the release.

The standard build fetches its feature libraries and its interface languages on demand. The apks job builds full-intl with the English build’s resource IDs (the stableIds.txt AGP writes for the first build, passed to the second as -Pwmkb.stableResourceIds), publishes it, and cuts its universal APK into one signed resource-only APK per language with .github/scripts/language-packs.sh. aapt2 optimize --split writes each language as a split, which PackageManager.getPackageArchiveInfo refuses to read, so .github/scripts/language-pack-finish.sh gives each one a base manifest (package and versionCode only) before signing it. 0.6.3’s packs went out as splits and no phone could load one. It collects the project’s own libraries that the standard APK pins (.github/scripts/collect-natives.sh, which searches every jniLibs directory native/gates.json names), and the fdroid job does the same for the ones F-Droid’s build compiled from source, after checking that the APK pins, and does not package, every library the table has F-Droid build. Before the release goes public, the publish job uploads every one of them to the natives release of wasi-master/wmkeyboard-data, named <sha256>-<library> and never replaced. That upload needs a DATA_REPO_TOKEN secret with contents: write on the data repository, and the release fails without it rather than pointing phones at files that are not there.

The offline job adds the no-internet builds: full-en with wmkb.noInternet=true in arm64-v8a and universal, and full-intl the same way in universal only, for Android 7 to 10. From 0.6.4 both keep native delivery, so neither carries a feature library, and both take the libraries and language packs the standard build downloads as imports. The English one takes this release’s language packs, so the pack-ids job compares its resource IDs with the standard build’s and fails the release if they differ; the two jobs compile on separate runners and only the manifest should tell them apart. The offline job runs on its own runner because the flag applies to the whole Gradle invocation, and it fails the release if aapt2 dump permissions still finds INTERNET, since ML Kit’s manifest would put the permission back without a build error if the overlay ever stopped applying. The -nointernet- part of the name keeps the in-app updater of a regular build from reading these as an update. Every other input matches the apks job, so R8 should rename each the same way as its regular build; the publish job attaches a mapping only when the two differ, and warns when they do.

The release also carries the files you need to read a crash report from those APKs. The *-mapping.txt.gz files are the R8 mappings: full-en for the standard and offline builds, full-intl for the all-languages ones, and *-play-mapping.txt.gz. The file *-full-en-native-symbols.zip holds the native debug symbols. The two language builds are separate R8 runs, so their mappings are not interchangeable even though the Kotlin that went in was identical. Retrace a stack trace with the mapping from the same language build and version:

Terminal window
gunzip -k wmkeyboard-0.5.9-vc24-full-intl-mapping.txt.gz
retrace wmkeyboard-0.5.9-vc24-full-intl-mapping.txt stacktrace.txt

The fifth one comes from the bundle job rather than the apks job, and it is there because the Play build is compiled with different channel flags, so R8 renames it differently and neither of the other two fits a crash from a Play install. Play Console reads the copy inside the AAB and needs no upload; a reporter on GitHub cannot reach that copy, and neither can the retrace workflow below, so it is published like the rest.

Nobody has to do any of the above by hand for a crash pasted into an issue. retrace.yml watches new issues and comments, and when a body holds frames that look obfuscated it reads the version: line out of the app’s own crash record, fetches the matching mapping off that release, runs R8’s retrace over the whole body, and comments the result.

What makes the lookup deterministic rather than a guess is that the record names its build. DebugLog writes version: 0.6.3 (370) en (Play Store), and the flavor there is :app’s languages one, which :app hands the log on startup: :core:common compiles against :core:config, which has no flavors and could not tell intl from en. Records from 0.6.2 and earlier say fullEn, liteIntl and so on, and the bot still reads those. A record from an F-Droid install is refused with a note instead: the F-Droid build’s mapping is not published. From 0.5.10 that build is identical to the release’s -fdroid.apk, so its mapping could be.

Why a mapping is checked before it is used

Section titled “Why a mapping is checked before it is used”

A wrong mapping does not fail. It renames every frame, into names that read perfectly and mean nothing. #271 was a crash in the translate panel, from an F-Droid install, and the first version of this workflow retraced it into WmSlider and SoundPack because it picked whichever mapping renamed more frames, 57 against 56.

So before anything is retraced, each candidate mapping is checked against the trace: for a frame a.b(SourceFile:12), the mapping has to hold class a, a member renamed to b inside it, and a line range around 12. Matching the class alone proves nothing, because every mapping of this app holds thousands of two-letter class names. The method and line together are what a different R8 run cannot fake.

The separation is absolute, and measured rather than assumed. Across #252, #253, #237 and #271 the correct mapping accounts for 100% of the frames and every wrong one for 0–20%, with nothing in between. Below 60% nothing is posted but a note saying so, which is what #271 gets now.

Commenting /retrace on an issue re-runs it against the issue body, which is what to do when a trace was pasted before its release was published.

The workflow treats every issue body as hostile text: it reaches the script through the environment and a file, never through a shell argument or a workflow expression inside a run: block, and the retraced output goes back inside a fence long enough that no backtick run in it can escape.

  1. Base64 your keystore so it can live in a secret:

    Terminal window
    base64 -i release.keystore | pbcopy
  2. Add the signing secrets under Settings → Secrets and variables → Actions → New repository secret:

    SecretValue
    KEYSTORE_BASE64the base64 blob from step 1
    RELEASE_STORE_PASSWORDkeystore password
    RELEASE_KEY_ALIASkey alias
    RELEASE_KEY_PASSWORDkey password
  3. Add the API-key secrets you want baked into public builds: WMKB_KLIPY_API_KEY, WMKB_GIPHY_API_KEY, WMKB_BRAVE_API_KEY, WMKB_TRANSLATE_API_KEY, WMKB_UNSPLASH_API_KEY, WMKB_PEXELS_API_KEY, WMKB_DROPBOX_APP_KEY, WMKB_ONEDRIVE_CLIENT_ID. Any you leave out make that tool show its “needs an API key” state, or leave that backup destination off the list, so a release without them still builds and runs.

  1. Bump the version. wmkb.versionCode and wmkb.versionName in gradle.properties. The code must increase; the name is what the tag has to match.

  2. Write the store changelog. fastlane/metadata/android/en-US/changelogs/<versionCode>.txt. F-Droid and Play both read this file, so it is usually the only place you write it. Play caps it at 500 characters. When Play needs different notes from F-Droid, put Play’s under fastlane/play/metadata/android/en-US/changelogs/<versionCode>.txt: the internal lane uploads that one instead, and F-Droid keeps the shared file. 0.5.10 does this, because its F-Droid notes open with the one-time reinstall for reproducible builds.

    Then run scripts/changelog-abi-copies.sh and commit what it writes. From 0.6.2 every manifest’s versionCode is wmkb.versionCode × 10 plus a digit for the ABI (abiDigits in app/build.gradle.kts), and both stores look the notes up by that number: F-Droid wants 351.txt, 352.txt and 353.txt beside its three APKs, Play wants 350.txt beside the bundle, and neither falls back to 35.txt. The script copies the file you wrote to those names. The release workflow’s first job and the Fastfile’s preflight both stop when a copy is missing or stale, since F-Droid reads them from the tagged commit.

  3. Write the release notes. release-notes/<versionName>.md, in the repo root. This is the prose at the top of the GitHub release: what changed, in sections, with issue links. Leave it out and the release falls back to the store changelog, which is a paragraph. That is fine for a bug-fix release and thin for anything else.

  4. Tag and push.

    Terminal window
    git tag v0.3.1 && git push origin v0.3.1
  5. Watch the run. It builds the APKs, builds the Play AAB as a separate artifact, and publishes the release. The AAB is not attached to the public release, because an AAB is not installable. The play job uploads it to the Play internal track through fastlane instead, or skips itself with a warning if the PLAY_SERVICE_ACCOUNT_JSON secret is not set. Promoting to production stays a decision you make by hand.

    The release is announced on the Telegram community only by the run that publishes it. Re-running the workflow, or moving the tag onto a fixed commit and pushing it again, rebuilds and re-uploads every file into the same release and posts nothing.

    To release to GitHub and F-Droid alone, put [skip play] in the message of the commit the tag points at. The play job then stands down, and the issue comments name F-Droid as the only store to wait for. This is for a version Play already has under an older versionCode: uploading the new code would send production back through review for nothing. 0.5.13 did this, as versionCode 28 on GitHub and F-Droid and 27 on Play.

  6. Pull the settings-link refresh. Pushing the tag also starts the Settings links workflow (.github/workflows/settings-links.yml). It reruns scripts/extract_settings_links.sh and scripts/extract-settings-since.mjs, which read the tags to find which release first has each settings screen and row, and commits src/data/settings-since.json back to main. The /open/ page, the link builder and the settings chips show that as “Added in version …”. Until the workflow finishes, what the new tag shipped still reads Not released yet on the site. Pull main before you push again.

Once the release is published, the issue-comments job calls release-comments.yml, which comments on every issue the release notes link. The comment names the version, mentions whoever opened the issue, lists the commits behind it, and says that F-Droid and Google Play take 2 to 3 days to catch up with the GitHub release. So the issue links you write in release-notes/<versionName>.md are also the list of people who hear about it.

When the change added settings, the comment links each one through https://wmkeyboard.pages.dev/open/, with since= set to the release, and notes that the links open only once that version is installed. Nothing records which settings belong to which issue, so the script works it out:

  1. The commits. Every commit between the previous tag and this one whose message names the issue (Closes #285, Refs #285), the commits GitHub tied to it on the issue’s timeline (a reference, the closing commit, a merged pull request that mentions it), and any commit a maintainer’s comment names by hash. A commit outside the release’s range never counts.
  2. The settings. A <string name> those commits added under src/main/res/values/ that the previous tag did not have, and that docs/src/data/settings-links.json lists as a screen or a row. A row’s link name is the resource name of its title, so this is an exact match. A new screen stands for the rows on it, so a new tool is one link, not twenty.

It runs from the release workflow rather than on release: published because the release is published with the workflow’s own token, and GitHub starts no workflow for an event that token caused. The release trigger is still there for a release you publish by hand. Every comment carries a hidden marker for its tag, so a second run for the same release posts nothing twice: it skips an issue it already told, or updates the comment if the text has changed.

Rehearse against any release that is already out, from the Actions tab (Release comments, dry run on by default, comments land in the run summary) or locally:

Terminal window
python3 .github/scripts/release_comments.py --tag v0.5.11 --dry-run
python3 .github/scripts/release_comments.py --tag v0.5.11 --dry-run --only 285,302

A draft, a pre-release, or a release with no APKs attached is left alone.

.github/scripts/release-notes.sh builds it in the publish job, after the APKs have been downloaded back from the apks job. Three parts:

  1. What’s new: release-notes/<versionName>.md verbatim, inside a <details open> so it can be folded away, or the store changelog if that file is absent.
  2. Download: a grid of architecture against build, starred on arm64-v8a, every cell carrying the file’s real size. The sizes are measured from the artifacts that were actually built, so a cell whose file is missing shows an em dash rather than a link to a 404. Checksums, R8 mappings and native symbols sit in a collapsed block under it.
  3. One line giving the commit count since the previous tag, linking the compare view.

The publish job then makes the release as a draft, uploads the files one at a time, and only publishes it once they are all attached, so a watcher never sees a release with half its APKs on it. It uses the gh CLI rather than softprops/action-gh-release, which failed the 0.5.9 publish with Error creating asset temp dir after attaching three of twelve files. The whole step is idempotent, so re-running it after a failure edits and re-uploads over whatever the last attempt managed.

There is deliberately no generated list of commit subjects. The prose in release-notes/ is the changelog; the compare view is a better place to read every commit than a wall of subjects in the release body.

Preview the whole body without cutting a release, against artifacts you already have in dist/:

Terminal window
.github/scripts/release-notes.sh 0.5.9 20 v0.5.9 dist wasi-master/wmkeyboard

.github/scripts/backfill-release-notes.sh puts a published release’s body through the same generator, and creates the release when a tag has none:

Terminal window
.github/scripts/backfill-release-notes.sh --dry-run v0.4.0
.github/scripts/backfill-release-notes.sh v0.4.0 v0.4.1

It downloads nothing. The asset list from the API carries each file’s byte count, and the script writes sparse stand-ins of exactly that size for the generator to measure, so the grid ends up with the real numbers. Two details it handles on its own: 0.3.0 predates the versioned filenames, so its grid is pointed back at the app-<flavor>-<abi>-release.apk names actually attached; and a release it creates passes --latest=false, because a tag cut after the fact must not take the Latest badge that the in-app updater reads.

F-Droid does not take your APK as it is. It builds from source on its own machines, and publishes our signature only after its build turns out identical to ours (see Reproducible builds below). Building from source is why wmkb.enableFdroid exists. Every proprietary dependency in the tree (ML Kit, LiteRT, LiteRT-LM, Play’s update and feature delivery libraries, play-services-auth) is declared only when that flag is off or sits behind another channel flag, and the few classes that call ML Kit directly have stand-ins in src/nogoogle, so the F-Droid build has no Google artifact on its compile classpath at all. Up to 0.6.2 the same job was done by a lite product flavor, which is gone.

The recipe’s gradle: list still names that flavour, lite. fdroidserver joins the list into a task name, so what it runs is assembleLiteRelease, which AGP does not register: :app’s only flavor dimension is languages.

So app/build.gradle.kts registers assembleLiteRelease itself, as an alias for assembleIntlRelease. The alias is there because the recipe cannot be counted on to change. AutoUpdateMode: Version writes each new Builds: entry by copying the last one, gradle: [lite] included, and nobody reviews what the bot writes.

The alias does one thing besides run the build. fdroidserver looks for the APK in the directory under app/build/outputs/apk/ whose name matches its flavour list, which is lite, and the build writes to intl/. Without help that ends in Failed to find any output apks after a build that succeeded. The alias therefore mirrors the APK into apk/lite/release/, where a lite build used to put it. It is a Sync and not a Copy, because fdroidserver also refuses a directory that holds more than one APK.

A merge request to fdroiddata that switches the recipe to gradle: [intl] retires the alias; until then it stays.

F-Droid gets intl on purpose. It ships one APK to everybody and nothing downstream of the build adds a language, so the APK is the only place an F-Droid user can get the translations from. That is worth about 41 MB. The standard English APK is on the GitHub release, and from 0.5.10 it installs over an F-Droid install like any update, because both carry the same key.

The build recipe is staged in the repo at fdroid/com.wasimaster.wmkeyboard.yml. F-Droid never reads it from there; it is versioned next to the code so the recipe and the build stay in step. It carries no comments, because fdroiddata’s CI runs fdroid rewritemeta on every changed file and fails if the result differs by a byte. rewritemeta strips comments, so any it found would trip that check. Field-by-field reasoning lives in fdroid/README.md instead.

  1. Check the app is buildable as-is. Nothing generated may be missing from source. The dictionaries are fine here: the two bundled .wmdict files are compiled at build time by :tools:dictc from the plain-text lists in app/dictionaries-src/, and .wmng n-gram packs are downloaded at runtime rather than built at all. No binary dictionary is committed. Apart from gradle/wrapper/gradle-wrapper.jar, which F-Droid checks against its own known-good list, the only prebuilt binaries in the tree are native libraries. Some are ones F-Droid cannot build from source and its build goes without (Tesseract in :core:intelligence’s src/tesseract/jniLibs, Whistle in :core:voice’s src/whistle/jniLibs, the LLM module’s placeholders). The rest, the text engines, Harper, whisper.cpp, ExecuTorch and every other library native/gates.json gives an fdroidBuild script, ship in its build too. The recipe deletes all of them before the source scan, then its build: step runs native/build-fdroid-libs.sh (or build-lite-libs.sh, its old name, which the copied recipe entries still call), which compiles them from source with the NDK the recipe names, Rust and CMake.

  2. Test the exact build before you submit, because a failed build on their side means a round trip. The release workflow’s fdroid job runs F-Droid’s own fdroid build in F-Droid’s buildserver image, from the recipe in this repo, so a dry run of the workflow is that test:

    Terminal window
    gh workflow run release.yml -f dry_run=true

    The Gradle half alone is quicker to check on your own machine:

    Terminal window
    ./gradlew clean assembleLiteRelease -Pwmkb.enablePlayStore=false -Pwmkb.enableGms=false -Pwmkb.enableFdroid=true

    That is the task name fdroidserver runs. Afterwards app/build/outputs/apk/lite/release/ must hold exactly one APK, since that is where it will look.

  3. Run the three checks their CI runs. fdroid lint must exit 0, and fdroid rewritemeta and fdroid checkupdates --auto must both leave the file byte-identical, since a pipeline job diffs the file after each. lint needs fdroiddata’s own config/ directory to know the valid category and anti-feature names; fdroid/README.md has the copy-paste block that fetches it and runs all three.

  4. Fork fdroiddata on GitLab, make a branch named after the application id, and add the recipe as metadata/com.wasimaster.wmkeyboard.yml. Doing it through the GitLab web UI is the documented route and avoids cloning fdroiddata, which is a large repository and fails to clone outright on some networks. Never commit to your fork’s master.

  5. Wait for your fork’s pipeline to go green (CI/CD → Pipelines in the fork), then open a merge request titled New app: WM Keyboard and fill in their template. Expect review comments and a wait measured in weeks rather than days. The two questions that come up most are whether every network service is optional, and whether anything prebuilt ends up in the APK. Every service is optional, and since 0.5.6 each one can also be pointed at a server the user runs, which is why the recipe carries no AntiFeatures block. Nothing prebuilt ends up in the APK either.

  6. After it is accepted, releases look after themselves. UpdateCheckMode: Tags walks the newest tags, and UpdateCheckData points it at gradle.properties as checked out at each one, so it reads the version a tag actually carries. AutoUpdateMode: Version then adds the next Builds: entry and pins it to that tag’s full commit hash. F-Droid wants hashes, not tags, because a tag can be moved after a build is accepted. What that costs you is a promise: a release whose build needs something the recipe does not do has to be caught before the tag is pushed, because nobody reviews the entry their bot writes.

From 0.5.10 the F-Droid build is signed with the same key as the GitHub APKs. F-Droid still compiles it: the recipe’s Binaries: line points at wmkeyboard-<version>-vc<code>-fdroid.apk on the GitHub release, and after F-Droid’s own build finishes it downloads that file and compares the two. If they are identical apart from the signature, it copies our signature onto its build and publishes that. If they are not, that version fails and does not ship on F-Droid. AllowedAPKSigningKeys pins the certificate, so a reference APK signed with anything else is refused as well.

The fdroid job in the release workflow builds that file, and it does not imitate F-Droid’s build: it runs it. .github/scripts/fdroid-build.sh installs fdroidserver master in F-Droid’s buildserver image, writes the recipe from this repo with one entry for the commit being released, and runs fdroid build, the same steps as the fdroid build job in fdroiddata’s CI. The job then signs the unsigned APK with apksigner sign --alignment-preserved, checks with apksigcopier compare that the signature carries back onto it, and checks the engines F-Droid compiles are inside. Until 0.5.14 the job ran assembleLiteRelease with the recipe’s flags instead, which was enough while the recipe was Gradle alone. From 0.6.0 the recipe compiles Harper, whisper.cpp and ExecuTorch with the NDK and Rust, and only fdroidserver itself reproduces that faithfully.

From 0.6.2 there are three of everything. Up to 0.6.1 F-Droid built one APK with all three ABIs’ native code in it, which made 0.6.0’s 98 MB where the arm64 build alone is 82 MB (#801). The recipe now has one Builds: entry per ABI, each setting wmkb.abi (Gradle packages that ABI alone and gives it its digit) and WMKB_ABIS (native/build-fdroid-libs.sh compiles that ABI alone), and VercodeOperation tells their bot to write three entries per tag. F-Droid serves each phone the highest versionCode it can run. %c in Binaries: is each entry’s full code, so the reference APKs are wmkeyboard-<version>-vc<code × 10 + digit>-fdroid.apk, and the fdroid job is a matrix over the three ABIs, each running fdroid-build.sh with its ABI and checking that no other ABI’s code is in its APK.

The rules that follow from this:

  • Never re-upload the -fdroid.apk of a release that is already out. F-Droid fetches it days after the tag, and a file built from anything other than the tagged commit fails the comparison.
  • Anything that makes two builds of one commit differ breaks F-Droid. A timestamp or a random value in BuildConfig, a dependency pulled by a version range, a JDK toolchain other than the one their image carries. When a build fails the comparison, their build log holds the diff.
  • Native code must not remember where it was built. A path in a compiled library (__FILE__ in an assert, a Rust panic message, a libc++ header) differs between F-Droid’s machine and the runner. The scripts under native/ map every such path away and pin every tool: the NDK in the recipe, Rust and cargo-ndk in harper-jni/build.sh, and the commits of whisper.cpp, ExecuTorch and PyTorch in theirs. A new native library joins the F-Droid build the same way, through the fdroidBuild script of its entry in native/gates.json, or has none and stays out of it.
  • Build it from a real clone. AGP writes the commit hash into META-INF/version-control-info.textproto. From a git worktree or a tarball it cannot read the repository and writes NO_VALID_GIT_FOUND instead, and that one file fails the comparison. The fdroid job checks the hash is there.
  • No dependency metadata in the signature. AGP signs a Google-encrypted dependency list into an APK’s signing block by default, and fdroiddata’s check apk job rejects the reference APK for it. dependenciesInfo { includeInApk = false } in app/build.gradle.kts turns it off. It sits outside the files F-Droid compares, which is why the comparison itself still passed on 0.5.10’s first upload. That APK was re-signed with apksigner sign --alignment-preserved to drop the block.
  • Keep the certificate check in the fdroid job in step with the recipe if the signing key ever changes.

The switch cost one reinstall. Installs from F-Droid up to 0.5.9 were signed with F-Droid’s key, and Android will not update an app to a build signed with a different one. Those users had to export a backup, uninstall, and install 0.5.10 again. Dual signing (F-Droid publishes both signatures for each version, as Liseur does) would have spared them, at the price of a hand-made fdroiddata merge request for every release. It was turned down.

The two editions are not the same app, so they do not share a description.

TreeRead byHolds
fastlane/metadata/android/en-US/F-Droid, and the shared source for images and changelogsthe F-Droid build’s title.txt, short_description.txt and full_description.txt, plus images/ and changelogs/
fastlane/play/metadata/android/en-US/Play, via supply’s metadata_paththe same three text files, written for the standard build, and nothing else

F-Droid scans a fixed set of paths inside the source repo and there is no way to point it somewhere else, so the default tree has to be the F-Droid one. Play is the side that can be redirected, so Play is the side that moved.

Only the three text files diverge. Images and changelogs stay in the shared tree, uploaded by graphics and internal, which read the default path. One copy of each, nothing to keep in step. The exception is a changelog placed under the Play tree for a version whose Play notes must differ, which internal prefers when it exists.

  1. Create the app in the Play Console with package name com.wasimaster.wmkeyboard.

  2. Fill in App content before you upload anything, since these answers gate the release. A keyboard is a sensitive category and reviewers do read them:

    • Privacy policy URL: https://wmkeyboard.pages.dev/privacy/policy/, the Play one. There are two, because the F-Droid build and the others are not the same app underneath: the Play and GitHub builds carry ML Kit and the Play build its libraries, and have to disclose what they report, and the build F-Droid makes carries neither. F-Droid’s own policy is at /privacy/policy-fdroid/. Never point the Play listing at that one, because it would under-disclose. The About screen picks the right page from BuildConfig.GOOGLE_SDKS, so a user always reads the policy for the APK they actually installed.
    • Data safety: this project collects nothing and shares nothing. The optional network tools send what you typed into that tool to the service you chose, which belongs under that tool’s own disclosure rather than as app-wide collection. But the form covers bundled SDKs as well as your own code, and the Play build bundles ML Kit. Google documents that its SDKs send device and app information, a per-installation identifier, and performance and error data to Google for diagnostics and usage analytics whenever an ML Kit feature runs. Declare that under App info and performance → Diagnostics and Device or other IDs: collected, not shared, encrypted in transit, and not deletable on request, since Google offers no opt-out. Answering “no data collected” here is the mismatch that gets a keyboard rejected, because the reviewer can see the SDK in the bundle. Read Network policy while you fill it in.
    • AI-generated content: yes. The AI writing tools, the AI chat screen and the on-device models all produce generated text, and the policy asks for an in-app way to report a bad generation. Every surface that shows generated text has one: the keyboard’s AI panel, and the chat screen’s per-answer Report. So the declaration has something true to point at.
    • Target audience: not children. A keyboard aimed at children pulls in Families policy, which this app is not built for.
    • Ads: none. Financial features: none. News: no.
  3. Fill the two declaration forms this manifest forces. Only two of the four sensitive surfaces have a form of their own; the other two are judged on the in-app disclosure and the Data safety answers instead. Knowing which is which saves hunting the Console for a page that does not exist.

    SurfaceConsole form?What to say
    Accessibility API, from TouchPassthroughService binding BIND_ACCESSIBILITY_SERVICEYes, and because isAccessibilityTool="true" it also wants a video of the feature and the disability audienceIt carves the key grid out of TalkBack’s touch exploration so screen-reader users can still use the keyboard’s gestures. It subscribes to no accessibility events, cannot read window content, and is inert unless pass-through is switched on.
    Photo and video permissions, from READ_MEDIA_IMAGESYesIt feeds the screenshot observer that offers a just-taken screenshot on the strip. The Photo Picker cannot replace it: a picker returns a file the user chose, and this needs to notice that a new one appeared. FUTO Keyboard ships the same permission for the same reason on Play, so this is a well-trodden justification rather than a novel one.
    Notification access, from MediaNotificationListenerNo form. Prominent disclosure plus Data safety is the whole requirementTwo jobs, both user-facing: the media-control tool needs the active media session, and one-time-code capture reads codes to offer them on the strip. Both are off until the user enables them, and SpecialAccessActivity shows the disclosure before the system screen opens.
    PACKAGE_USAGE_STATS, for the clipboard’s “Show source app” settingNo form. Same as aboveOn a copy it reads which app was in the foreground in the ten seconds before, so the clip can say where it came from. It reads no history and no time-in-app, keeps the answer with the clip on the device, and the setting is off until the user turns it on.
  4. Check the sensitive-permission story matches what the app says. The label on the notification listener is what a user reads in Settings before granting it, and it has to name both jobs. The in-app prominent-disclosure screens are what the policy asks for, and they exist for every runtime permission already.

  5. Build and upload to internal testing. Tagging a release does this for you (the play job in the workflow), or run it locally:

    Terminal window
    bundle exec fastlane android internal

    Go to internal first, always. Play’s own device catalogue tells you within minutes if an on-demand module (:feature:llm, :feature:translate, :feature:litert, :feature:handwriting, :feature:speech, :feature:grammar, :feature:spell, :feature:ocr, :feature:bergamot, :feature:ct2, :feature:zinnia, :feature:llama, :feature:docscan, :feature:imagegen) or the unbundled ML Kit path is misconfigured, and that is far cheaper to learn there than in production.

  6. Promote when it looks right.

    Terminal window
    bundle exec fastlane android promote

    Add rollout:0.2 for a staged rollout. Play signs with its own upload key flow, so the APK a user installs is not byte-identical to anything you built. That is expected, and it is why the GitHub release exists for people who want to verify a checksum.

The lanes live in fastlane/Fastfile and run through Bundler so the version is pinned by Gemfile.lock (CI installs the same one). One-time setup on a new machine:

Terminal window
brew install ruby@3.4
export PATH="/opt/homebrew/opt/ruby@3.4/bin:$PATH" # add to ~/.zshrc
bundle install
LaneWhat it does
preflightchecks the changelog for the current versionCode exists, signing is configured, and both listing trees are within Play’s character limits
bundle_playbundleEnRelease with the Play channel flags. Play always gets en: it translates the app strings itself and injects them into the bundle at upload, and the translations in the repo were recovered from Play to begin with, so an intl bundle would only hand them back
apkssideload APKs in both language builds, one per ABI
internalchecks the changelog, builds the AAB (or takes aab:path), uploads to the internal track. Only the build path checks signing, since a prebuilt aab: is assumed already signed
promotemove the internal release to production (to:, from:, rollout: options)
listingpush the Play store-listing text, from fastlane/play/metadata/, nothing else
graphicspush the icon, feature graphic and screenshots to Play, nothing else
check_keyvalidate the service-account key against the Play API

Every lane passes the channel flags as -P properties, which beat whatever your local.properties has, so a machine set up for Play builds still produces a correct sideload build and the other way round.

internal and the tag-triggered upload only touch the binary and the release notes. The listing text is a separate, deliberate listing run and the imagery a separate graphics run, so a routine release can never half-rewrite the store page.

fastlane/metadata/.../images/ is what both stores read: F-Droid takes it straight out of the source tree, and the graphics lane uploads the same files to Play. play/store-listing/ and play/store-listing-tablet10/ are the composed slides those copies come from.

  1. Link a Cloud project. Play Console → Setup → API access, link (or create) a Google Cloud project.

  2. Create a service account in that Cloud project (IAM & Admin → Service accounts). No project roles are needed. Create a JSON key for it and download it.

  3. Invite the service account in Play Console → Users and permissions, using its ...@...iam.gserviceaccount.com address. Grant it release permission for this app (releases to testing tracks; add production if promote should work from the CLI too).

  4. Put the key where fastlane looks. Locally that is fastlane/play-service-account.json (git-ignored). For CI, paste the whole JSON as a repository secret named PLAY_SERVICE_ACCOUNT_JSON.

  5. Prove it works:

    Terminal window
    bundle exec fastlane android check_key

The site at wmkeyboard.pages.dev is a Cloudflare Pages project connected to this repository, so a push to main that touches docs/ deploys itself. The settings are:

SettingValue
Project typePages, not Workers
Project namewmkeyboard (this is what makes the hostname)
Framework presetAstro
Build commandnpm run build
Build output directorydist
Root directorydocs
Node version22 (set NODE_VERSION=22 if the default is older)

CI builds the site with npm run check on every pull request, which turns on the link validator, so a broken internal link fails there rather than shipping.