# Releasing

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

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

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.

## What the channels actually differ on

| | GitHub release | F-Droid | Play Store |
|---|---|---|---|
| Flavor | standard: `en`; offline: `en`, and `intl` for its all-languages build (`intl` is also built to cut the language packs from) | `intl` | `en` |
| Gradle task | `assembleEnRelease` and `assembleIntlRelease` | `assembleLiteRelease`, an alias for `assembleIntlRelease` | `bundleEnRelease` |
| `wmkb.enablePlayStore` | `false` | `false` | `true` |
| `wmkb.enableGms` | `true` | `false` | `true` |
| `wmkb.enableFdroid` | `false` | `true` | `false` |
| `wmkb.splitApks` | `true` | not used | ignored by `bundle*` |
| `wmkb.enableCrashScreen` | not set | not set | `false`, forced by the Fastfile over `local.properties` |
| Automation `TYPE_TEXT` | offered | offered | left out |
| Artifact | the 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` release | one 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 ABI | one 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 `universal` | the same three digits, one APK each | `× 10`, digit 0 |
| ML Kit | bundled in the APK | absent | unbundled, fetched from Play services |
| Local LLM | bundled | absent | on-demand `:feature:llm` module |
| On-device translation (ML Kit) | bundled | absent | on-demand `:feature:translate` module |
| LiteRT interpreter (offline Whisper, the sticker editor's own background remover) | bundled | absent | on-demand `:feature:litert` module |
| Handwriting recognition (ML Kit) | bundled | absent | on-demand `:feature:handwriting` module |
| Text engines (Avro, Khipro, Bijoy, Hindi, Urdu, the Indic languages) | committed prebuilt library | compiled from source by the recipe | the same prebuilt library, in the base module |
| Harper, whisper.cpp, ExecuTorch (grammar, Offline Whisper, Asr4All, Parakeet, Moonshine, the FUTO Swipe glide engine) | committed prebuilt libraries | compiled from source by the recipe | the 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 demand | compiled from source by the recipe | the 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's | compiled from source by the recipe | the 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 APK | compiled from source by the recipe, pinned the same way | the 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 demand | compiled from source by the recipe | the 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 demand | compiled from source by the recipe | the 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 demand | compiled from source by the recipe | the 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 demand | compiled from source by the recipe | the same prebuilt library, in the on-demand `:feature:zinnia` module |
| OpenCV (the built-in document scanner) | committed prebuilt library, pinned and downloaded on demand | compiled from source by the recipe | the same prebuilt library, in the on-demand `:feature:docscan` module |
| MNN (the sticker generator) and its NPU runner | committed prebuilt libraries, pinned and downloaded on demand | MNN compiled from source by the recipe; no NPU runner | the same prebuilt libraries, in the on-demand `:feature:imagegen` module |
| Whistle, Tesseract (Whistle dictation, the scanner's offline reader) | committed prebuilt libraries, full only | absent | the same prebuilt libraries, in the on-demand `:feature:speech` (Whistle, arm64 only) and `:feature:ocr` (Tesseract) modules |
| In-app updates | GitHub releases, downloads and installs | checks F-Droid, links out | Play In-App Updates |
| Native libraries in the APK | uncompressed and page-aligned | uncompressed and page-aligned | uncompressed and page-aligned; Play compresses the download itself |
| Bundled layouts | JSON compacted at build time | same | same |

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 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. F-Droid's own client installs F-Droid builds, and
installs up to 0.5.9 carry F-Droid's key rather than ours.

## Where the updater code lives

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.

| Directory | Compiled when | Holds |
|---|---|---|
| `app/src/play/java` | `enablePlayStore` | `PlayAppUpdater`, and SplitInstall for `:feature:llm` and `:feature:translate` |
| `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:

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

## Cutting a GitHub release

The [`Release` workflow](https://github.com/wasi-master/wmkeyboard/blob/main/.github/workflows/release.yml)
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](https://wmkeyboard.pages.dev/development/building/#a-build-with-no-internet-permission):
`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:

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

### The retrace workflow

Nobody has to do any of the above by hand for a crash pasted into an issue.
[`retrace.yml`](https://github.com/wasi-master/wmkeyboard/blob/main/.github/workflows/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

A wrong mapping does not fail. It renames every frame, into names that read
perfectly and mean nothing. [#271](https://github.com/wasi-master/wmkeyboard/issues/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.

### One-time setup

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

   ```sh
   base64 -i release.keystore | pbcopy
   ```

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

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.

### Every release

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](#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.**

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

### Telling the issues

Once the release is published, the `issue-comments` job calls
[`release-comments.yml`](https://github.com/wasi-master/wmkeyboard/blob/main/.github/workflows/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:

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

### What the release body looks like

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

Actions runs the workflow file as it was at the ref that triggered it, so
fixing `release.yml` on `main` does nothing for a re-run of a tag cut before
the fix. Attach the files by hand in that case: `gh run download <run-id>
--name release-apks`, then `gh release upload <tag> --clobber <files>` and
`gh release edit <tag> --draft=false --latest`.

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/`:

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

### Rewriting a release that is already out

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

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

Run the workflow from the Actions tab with **dry run** left on. Everything
builds and uploads as workflow artifacts, and no release is created.

## Getting into F-Droid

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](#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`](https://github.com/wasi-master/wmkeyboard/blob/main/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:

   ```sh
   gh workflow run release.yml -f dry_run=true
   ```

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

   ```sh
   ./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.

   :::caution[No 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.
   :::

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`](https://github.com/wasi-master/wmkeyboard/blob/main/fdroid/README.md)
   has the copy-paste block that fetches it and runs all three.

4. **Fork [fdroiddata](https://gitlab.com/fdroid/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.

### Reproducible builds

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](https://github.com/wasi-master/wmkeyboard/issues/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.

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.

### Two listings, one repo

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 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_path` | the 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.

The F-Droid build has no ML Kit, no LiteRT and no Whistle, so it has no
handwriting, no text or QR scanning, no LiteRT Whisper, no Whistle dictation
and no on-device AI chat models. It does have the Harper grammar checker and
offline dictation through whisper.cpp and ExecuTorch, built from source. `fastlane/metadata/android/en-US/full_description.txt` names those as
*absent* and links [`/privacy/policy-fdroid/`](https://wmkeyboard.pages.dev/privacy/policy-fdroid/); the
Play copy describes them as present and links
[`/privacy/policy/`](https://wmkeyboard.pages.dev/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.

## Publishing to Play

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/`](https://wmkeyboard.pages.dev/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](https://developers.google.com/ml-kit/android-data-disclosure)
     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](https://wmkeyboard.pages.dev/privacy/network/) 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.

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

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:

   ```sh
   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.**

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

### Fastlane

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:

```sh
brew install ruby@3.4
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` | `bundleEnRelease` 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 |
| `apks` | sideload APKs in both language builds, 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.

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.

### One-time setup: the Play API key

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

   ```sh
   bundle exec fastlane android check_key
   ```

## Deploying the docs

The site at [wmkeyboard.pages.dev](https://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) |

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.
