-
Notifications
You must be signed in to change notification settings - Fork 4
Releasing
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 one flavor drive all of it, so nothing here needs a branch or a patched source tree.
| GitHub release | F-Droid | Play Store | |
|---|---|---|---|
| Flavor |
full and lite
|
lite |
full |
wmkb.enablePlayStore |
false |
false |
true |
wmkb.enableGms |
true |
false |
true |
wmkb.enableFdroid |
false |
true |
false |
wmkb.splitApks |
true |
not used | ignored by bundle*
|
| Artifact | one APK per ABI, for both flavors, plus R8 mappings, native symbols and SHA256SUMS.txt
|
one APK, built by F-Droid | one AAB, with its R8 mapping and native symbols inside |
| ML Kit | bundled in the APK | absent | unbundled, fetched from Play services |
| Local LLM | bundled | absent | on-demand :feature:llm module |
| In-app updates | GitHub releases, downloads and installs | checks F-Droid, links out | Play In-App Updates |
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.
Keep the flags matched to the channel
The flags pick the updater, and the wrong updater is worse than none.
wmkb.enablePlayStore=true links Play's in-app update library. That library
only works for an install that actually came from Play, and calling
completeUpdate() restarts the process, which kills a running keyboard. Never
ship a Play-flagged build anywhere but Play.
With neither flag set the build gets the GitHub updater, which downloads an APK and installs it. Two things follow. Play's Device and Network Abuse policy forbids an app distributed through Play from updating itself by any other route, so that code and its two permissions must never reach a Play build, which is what the source-set and manifest split below enforces. And a GitHub-channel build has to be signed with the release key, or it can never install over the published APK: Android refuses an update signed with a different key, and the app checks for that itself so the refusal at least arrives as a sentence rather than a silent failure.
wmkb.enableFdroid=true gets a checker that asks F-Droid and opens F-Droid,
and no installer at all, since F-Droid signs its own builds.
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 full/lite, and a second dimension would double every variant
and task name in the project.
| Directory | Compiled when | Holds |
|---|---|---|
app/src/play/java |
enablePlayStore |
PlayAppUpdater, and SplitInstall for :feature:llm
|
app/src/noplay/java |
otherwise | the no-op SplitInstall stub |
app/src/github/java |
neither flag | the release check, the download, and the package installer |
app/src/fdroid/java |
enableFdroid |
the F-Droid index check |
app/src/play/AndroidManifest.xml |
enablePlayStore |
a <queries> entry for the Play Store, so the app can see who installed it |
app/src/github/AndroidManifest.xml |
neither flag |
REQUEST_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:
rm -rf app/build/intermediates/merged_manifest
./gradlew :app:processFullDebugMainManifest -Pwmkb.enablePlayStore=true
grep -c REQUEST_INSTALL_PACKAGES \
app/build/intermediates/merged_manifest/fullDebug/processFullDebugMainManifest/AndroidManifest.xmlThat 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. It signs with your release key, then
attaches one APK per ABI for each flavor, plus a SHA256SUMS.txt, to the
release.
The release also carries the files you need to read a crash report from those
APKs. *-full-mapping.txt.gz and *-lite-mapping.txt.gz are the R8 mappings,
one per flavor, and *-full-native-symbols.zip holds the native debug
symbols. Retrace a stack trace with the mapping from the same flavor and
version:
gunzip -k wmkeyboard-0.5.8-vc19-full-mapping.txt.gz
retrace wmkeyboard-0.5.8-vc19-full-mapping.txt stacktrace.txtDo not use these files for a crash from Play. The Play build uses different channel flags, so R8 renames the code differently. Play needs no upload for this: the AAB carries its own mapping and symbols, and Play Console reads them from the bundle.
- Base64 your keystore so it can live in a secret:
base64 -i release.keystore | pbcopy- Add the signing secrets under Settings → Secrets and variables → Actions → New repository secret:
| Secret | Value |
|---|---|
KEYSTORE_BASE64 |
the base64 blob from step 1 |
RELEASE_STORE_PASSWORD |
keystore password |
RELEASE_KEY_ALIAS |
key alias |
RELEASE_KEY_PASSWORD |
key password |
-
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. Any you leave out just make that tool show its "needs an API key" state, so a release without them still builds and runs.
-
Bump the version.
wmkb.versionCodeandwmkb.versionNameingradle.properties. The code must increase; the name is what the tag has to match. -
Write the changelog.
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt. F-Droid and Play both read this file, so it is the only place you write it. -
Tag and push.
git tag v0.3.1 && git push origin v0.3.1-
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
playjob uploads it to the Play internal track through fastlane instead, or skips itself with a warning if thePLAY_SERVICE_ACCOUNT_JSONsecret is not set. Promoting to production stays a decision you make by hand.
Rehearse without publishing
Run the workflow from the Actions tab with dry run left on. Everything builds and uploads as workflow artifacts, and no release is created.
F-Droid does not take your APK. It builds from source on its own machines and
signs with its own key, which is why the lite flavor exists. Every
proprietary dependency in the tree (ML Kit, LiteRT, Play's update and feature
delivery libraries, play-services-auth) is declared fullImplementation or
sits behind a channel flag, so lite has no Google artifact on its compile
classpath at all.
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.
-
Check the app is buildable as-is. Nothing generated may be missing from source. The dictionaries are fine here: the two bundled
.wmdictfiles are compiled at build time by:tools:dictcfrom the plain-text lists inapp/dictionaries-src/, and.wmngn-gram packs are downloaded at runtime rather than built at all. No binary dictionary is committed. Apart fromgradle/wrapper/gradle-wrapper.jar, which F-Droid checks against its own known-good list, the only prebuilt binaries in the tree are the Harper.sofiles undercore/intelligence/src/full/and the LLM module's, both of which belong to variants alitebuild never assembles. The recipe deletes them before building so the scanner has nothing to complain about. -
Test the exact build locally before you submit, because a failed build on their side means a round trip:
./gradlew clean assembleLiteRelease -Pwmkb.enablePlayStore=false -Pwmkb.enableGms=falseNo module may ask for a JDK toolchain
The recipe deletes the foojay resolver, because the scanner rejects anything
that could fetch a JDK over the network. Their builders also have
auto-provisioning switched off. Together that makes any jvmToolchain(n) in
the tree fatal unless n is a JDK their image already carries.
:tools:dictc asked for 17 and failed the build outright with
Cannot find a Java installation ... matching {languageVersion=17}. Set a jvmTarget
instead: it fixes the bytecode level without demanding a particular JDK be
installed.
-
Run the three checks their CI runs.
fdroid lintmust exit 0, andfdroid rewritemetaandfdroid checkupdates --automust both leave the file byte-identical, since a pipeline job diffs the file after each.lintneeds fdroiddata's ownconfig/directory to know the valid category and anti-feature names;fdroid/README.mdhas the copy-paste block that fetches it and runs all three. -
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'smaster. -
Wait for your fork's pipeline to go green (CI/CD → Pipelines in the fork), then open a merge request titled
New app: WM Keyboardand 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 noAntiFeaturesblock. Nothing prebuilt ends up in the APK either. -
After it is accepted, releases look after themselves.
UpdateCheckMode: Tagswalks the newest tags, andUpdateCheckDatapoints it atgradle.propertiesas checked out at each one, so it reads the version a tag actually carries.AutoUpdateMode: Versionthen adds the nextBuilds: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.
Screenshots and description come from this repo
F-Droid reads fastlane/metadata/android/en-US/ directly out of the source
tree: title.txt, short_description.txt, full_description.txt, the
changelogs/ folder and images/. Editing them here updates the F-Droid
listing on the next build, with no metadata merge request.
The two editions are not the same app, so they do not share a description.
| Tree | Read by | Holds |
|---|---|---|
fastlane/metadata/android/en-US/ |
F-Droid, and the shared source for images and changelogs | the lite edition'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_path
|
the same three text files, written for the full edition, 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 lite description must not promise what lite does not ship
The lite flavour has no ML Kit, no Whisper and no Harper, so it has no
handwriting, no text or QR scanning, no offline dictation and no on-device AI
models. fastlane/metadata/android/en-US/full_description.txt names those as
absent and links /privacy/policy-fdroid/; the
Play copy describes them as present and links
/privacy/policy/. Adding a feature to one description
means deciding, deliberately, whether it belongs in the other.
preflight runs ensure_listing_limits!, which checks both trees against
Play's caps (30 / 80 / 4000 characters). It reads as UTF-8 explicitly rather
than in the machine's locale, because the description quotes Bengali and a
non-UTF-8 locale otherwise raises rather than counting.
-
Create the app in the Play Console with package name
com.wasimaster.wmkeyboard. -
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, one per edition, because the two builds are not the same app underneath: the full edition carries ML Kit and the Play libraries and has to disclose what they report, and the lite edition F-Droid builds 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 fromBuildConfig.FLAVOR, 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 full edition 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.
- 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.
| Surface | Console form? | What to say |
|---|---|---|
Accessibility API, from TouchPassthroughService binding BIND_ACCESSIBILITY_SERVICE
|
Yes, and because isAccessibilityTool="true" it also wants a video of the feature and the disability audience |
It 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_IMAGES
|
Yes | It 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 MediaNotificationListener
|
No form. Prominent disclosure plus Data safety is the whole requirement | Two 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" setting |
No form. Same as above | On 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. |
-
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.
-
Build and upload to internal testing. Tagging a release does this for you (the
playjob in the workflow), or run it locally:
bundle exec fastlane android internalGo to internal first, always. Play's own device catalogue tells you within
minutes if the on-demand :feature:llm module or the unbundled ML Kit path
is misconfigured, and that is far cheaper to learn there than in
production.
- Promote when it looks right.
bundle exec fastlane android promoteAdd 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:
brew install ruby@3.4
export PATH="/opt/homebrew/opt/ruby@3.4/bin:$PATH" # add to ~/.zshrc
bundle install| Lane | What it does |
|---|---|
preflight |
checks the changelog for the current versionCode exists, signing is configured, and both listing trees are within Play's character limits |
bundle_play |
bundleFullRelease with the Play channel flags |
apks |
sideload APKs, full + lite, one per ABI |
internal |
checks 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 |
promote |
move the internal release to production (to:, from:, rollout: options) |
listing |
push the Play store-listing text, from fastlane/play/metadata/, nothing else |
graphics |
push the icon, feature graphic and screenshots to Play, nothing else |
check_key |
validate 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.
Screenshot shape is a hard Play rule
A Play screenshot's long side may not be more than twice its short side.
The phone slides are composed on a 1080x2400 device canvas, which is 2.22x.
The API refuses that, with an error naming neither the file nor the rule. Run
python3 play/pipeline/pad_phone_shots.py after you regenerate the slides. It
widens each one to 1200x2400 by replicating its edge columns, so the shape is
legal and exactly 2:1, with no art scaled or cropped. The graphics lane
re-checks every file before it uploads. The 2560x1440 tablet slides are 16:9
and already fine.
-
Link a Cloud project. Play Console → Setup → API access, link (or create) a Google Cloud project.
-
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.
-
Invite the service account in Play Console → Users and permissions, using its
...@...iam.gserviceaccount.comaddress. Grant it release permission for this app (releases to testing tracks; add production ifpromoteshould work from the CLI too). -
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 namedPLAY_SERVICE_ACCOUNT_JSON. -
Prove it works:
bundle exec fastlane android check_keyThe 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:
| Setting | Value |
|---|---|
| Project type | Pages, not Workers |
| Project name |
wmkeyboard (this is what makes the hostname) |
| Framework preset | Astro |
| Build command | npm run build |
| Build output directory | dist |
| Root directory | docs |
| Node version | 22 (set NODE_VERSION=22 if the default is older) |
Pages, not Workers
Cloudflare's newer "Connect to Git" flow creates a Worker and deploys with
npx wrangler deploy. Two things go wrong if you take it. A Worker is served
from workers.dev, not pages.dev, and the pages.dev URL is compiled into
the app. And wrangler deploy on an unconfigured project runs
astro add cloudflare, whose adapter does not support Astro 7 yet: the build
fails on beginContentEntryCollection missing from Astro's exports.
docs/wrangler.jsonc declares the project as Pages so that never runs.
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.
- Home
- Getting started
- Typing
- Languages
- Suggestions & correction
- Emoji & expression
-
Tools
- Clipboard manager
- Voice typing
- Offline voice (Whisper)
- Handwriting
- Scanner (OCR, QR, documents)
- Camera tool
- Translate
- Search, Wikipedia & dictionary
- Media controls
- AI chat
- AI tools
- Utility tools
- Snippets & text expansion
- Text editing & cursor tools
- Instruments
- Trackpad
- Calendar
- App launcher
- Learn from text
- Vocabulary
- Resize the keyboard
- The toolbar
- Themes & appearance
- Addons
- Plugins
- Privacy & security
- Accessibility
-
Reference
- Gesture cheat sheet
- Typing
- Hardware shortcuts
- Deep links & launcher shortcuts
- Key press
- File formats
- Dictionaries & words
- Importing from other keyboards
- Languages
- Importing from Espanso
- Appearance
- Keyboard themes
- Troubleshooting
- Glossary
- Keyboard font
- Easter eggs
- Icons
- Layout & size
- Key layouts
- Rows & bars
- Keyboard modes
- Emoji
- Phone number formats
- Tools
- Addons & plugins
- Reference - Accessibility
- Fingerprint lock
- Reference - Data saver
- Reference - Permissions
- Privacy
- Reference - Selection actions
- Servers
- Reference - Backup & restore
- About & diagnostics
- Statistics
- Settings A–Z
- Development