- AGP / Kotlin / library versions: pinned in the version catalog,
gradle/libs.versions.toml - Compile SDK: 36, Min SDK: 26, Target SDK: 35
- JVM Toolchain: 21 (all modules)
- NDK: required for Snappy native code; exact version pinned in the build config
- Build files: Kotlin DSL (
build.gradle.kts,settings.gradle.kts) with a version catalog ingradle/libs.versions.toml
Flavor dimension: playStoreApplicationId
| Flavor | Package | Purpose |
|---|---|---|
plain |
(default) | Open-source development build |
yuku_alkitab |
yuku.alkitab |
Production Indonesian version |
yuku_quick_bible |
yuku.alkitab.kjv |
Production English version |
sabda_alkitab |
org.sabda.alkitab |
SABDA partner version |
Each flavor can override resources in src/{flavor}/res/ and Java/Kotlin sources in src/{flavor}/java/.
- plainDebug — local development (works out of the box)
- plainRelease — release build of open-source version
- yuku_alkitabDebug/Release — Indonesian production
- yuku_quick_bibleDebug/Release — English production
- sabda_alkitabDebug/Release — SABDA partner
versionName is assembled at build time from version.properties at the repo
root plus a stage, so an artifact's name says which channel produced it:
| Stage | Selected by | Example | Used for |
|---|---|---|---|
dev |
the default | 5.0.0-dev.42 |
every ordinary build, local or CI |
beta |
-PversionStage=beta or VERSION_STAGE=beta |
5.0.0-beta.1 |
the Play open-testing track |
release |
-PversionStage=release or VERSION_STAGE=release |
5.0.0 |
the Play production track |
version.properties holds only versionBase (the marketing version) and
betaNumber. The dev counter is not stored: it is the number of commits since
versionBase last changed, so it advances once per commit on its own, is the
same for anyone building that commit, and restarts near zero for each release
line rather than counting the whole repo's history. A betaNumber bump does not
reset it, so a dev version name is never reused within one versionBase.
Finding that starting point needs real history, so CI checks out with
fetch-depth: 0. On a shallow clone the lookup finds nothing and the count
falls back to however many commits the clone happens to have.
Beta numbers are deliberately manual, because a number that moved on its own could not identify the build a tester is reporting against:
./gradlew bumpBetaNumber # betaNumber 1 -> 2
git commit -am "Bump to 5.0.0-beta.2"When a release ships, bump versionBase and reset betaNumber to 1 in the same
commit.
versionCode is independent of all of this: it is
(2_000_000 + minutes since 2026-01-01 UTC) * 10, so any later build outranks
any earlier one regardless of branch or stage, and Play never sees a number go
backwards. The * 10 reserves nine spare codes per minute in case per-ABI
splits are ever needed. Because it is derived from the clock, two builds of the
same commit normally get different codes; set VERSION_CODE to pin it, which is
what the release workflow does so all three flavors in a run share one code.
Two consequences worth knowing:
- A dev build published from
developgets a higherversionCodethan a beta built yesterday. That is only a problem if both reach the same Play track, so keep dev builds off Play entirely (they live on GitHub pre-releases). - If a production hotfix is ever built after a beta, its
versionCodewill exceed the beta's, and Play will serve that hotfix to testers as an upgrade. Rebuild and re-upload the beta afterwards to put testers back ahead.
./gradlew printVersion # versionStage / versionName / versionCode
./gradlew bumpBetaNumber # increment betaNumber in version.properties
./gradlew bundleProductionRelease # AAB for all three production flavors
./gradlew assembleProductionRelease # APK for all three production flavors# Development
./gradlew assemblePlainDebug # Build debug APK
./gradlew bundlePlainDebug # Build debug AAB
./gradlew installPlainDebug # Build and install on device
# Testing
./gradlew testPlainDebugUnitTest # Debug unit tests
./gradlew testPlainReleaseUnitTest # Release unit tests (ProGuard applied)
./gradlew testPlainDebugUnitTest --tests "fully.qualified.TestClass"
./gradlew testPlainDebugUnitTest --tests "*.TestClass.testMethod"
# Lint
./gradlew lintPlainDebugGitHub Actions workflow (.github/workflows/android.yml):
- Triggers on push/PR to
developandrelease/**branches - Ubuntu latest, JDK 21 (Zulu)
plain-debugjob: runstestPlainDebugUnitTest,testPlainReleaseUnitTest,assemblePlainDebug,bundlePlainDebugsigned-releasejob (pushes todevelopand same-repo PRs): builds and signs all production flavors using the proprietary overlay repo, uploads per-flavor artifacts, and ondeveloppushes publishes a GitHub pre-release. On PRs it also uploads apr-preview-apksartifact (APKs and metadata only — no AABs or mapping files)pr-apk-previewjob (same-repo PRs only): publishes those signed release APKs to a Cloudflare Worker with static assets and comments immutable*.workers.devdownload links on the PR
A second workflow, .github/workflows/release.yml, is manual
(workflow_dispatch) and builds the artifacts that actually go to Google Play.
See "Release workflow" below.
Both workflows stop at the signed artifacts. Nothing in CI talks to the Play Console; store releases are pushed by hand, see Google Play Publishing.
Each same-repo PR gets its signed release APKs published to the alkitab-pr Cloudflare Worker, and a comment linking to a download page. Fork PRs skip it — they have neither the signing key nor the Cloudflare token.
wrangler versions upload creates a new worker version per build, each with its own immutable URL (https://<8-hex>-alkitab-pr.<subdomain>.workers.dev), so builds never overwrite each other. tools/cloudflare/pr-preview/make_dist.py stages the APKs plus a generated index.html; render_comment.py renders the PR comment.
Configuration is two repository secrets — CLOUDFLARE_API_TOKEN (created from the "Edit Cloudflare Workers" token template) and CLOUDFLARE_ACCOUNT_ID. Without them the job skips cleanly, so CI stays green. The worker needs no manual creation: the first run bootstraps it via wrangler deploy, then retries the version upload.
Two caveats: the APKs are production-signed and share application IDs with the Play Store builds, so installing one replaces the installed app; and preview URLs are public with no documented expiry, so a build stays reachable until its version is deleted (Cloudflare Access can gate them if that is not acceptable).
Production release builds are pure Gradle:
ALKITAB_PROPRIETARY_DIR=/path/to/proprietary \
SIGN_KEYSTORE=/path/to/keystore \
SIGN_ALIAS=mykey \
SIGN_PASSWORD=secret \
BUILD_DIST=market \
./gradlew assembleYuku_alkitabReleaseRequired $ALKITAB_PROPRIETARY_DIR layout:
$ALKITAB_PROPRIETARY_DIR/
├── google-services.json # one file with client entries for every production applicationId
└── overlay/
├── yuku.alkitab/text_raw/ # real Bible text for yuku_alkitab
├── yuku.alkitab.kjv/text_raw/ # real Bible text for yuku_quick_bible
└── org.sabda.alkitab/text_raw/ # real Bible text for sabda_alkitab (a symlink to yuku.alkitab is fine if both ship the same Bible)
Environment variables:
ALKITAB_PROPRIETARY_DIR— directory matching the layout above. Required foryuku_alkitab,yuku_quick_bible,sabda_alkitab. Not used byplain.SIGN_KEYSTORE,SIGN_ALIAS,SIGN_PASSWORD— required to sign release builds ofplain,yuku_alkitabandyuku_quick_bible.SIGN_SABDA_KEYSTORE,SIGN_SABDA_ALIAS,SIGN_SABDA_PASSWORD— required to sign release builds ofsabda_alkitab. See "Signing keys" below.BUILD_DIST— distribution channel identifier embedded in the APK filename. Defaults todevwhen unset.VERSION_STAGE—dev(default),beta, orrelease; picks howversionNameis assembled.-PversionStage=does the same and wins if both are given. See "Versioning" above.VERSION_CODE— pinsversionCodeinstead of deriving it from the clock, so a rebuild of the same commit produces the same number. The release workflow sets it once per run.
What the Gradle build does:
CopyProprietaryAssetsTask(per production flavor) copies the Bible assets in$ALKITAB_PROPRIETARY_DIR/overlay/<applicationId>/text_raw/intoAlkitab/build/generated/proprietaryAssets/<flavor>/internal/. Only the filesInternalReaderopens are taken (*.txtbook text plus the index, pericope, xrefs and footnotes Bintex files), so anything else the overlay happens to carry stays out of the APK. Wired into AGP viaandroidComponents { onVariants { ... addGeneratedSourceDirectory(...) } }so every consumer (mergeAssets, lint vital, etc.) automatically depends on it. Fails fast if the env var is unset or the overlay is missing.copyProprietaryGoogleServices<Flavor>(per production flavor) copies$ALKITAB_PROPRIETARY_DIR/google-services.jsonintoAlkitab/src/<flavor>/google-services.json, where the GMS plugin's source-set lookup picks it up. Those destinations are matched by the existinggoogle-services.jsonline in.gitignore, so they're never committed — they behave like build artifacts that just happen to live undersrc/. The plain flavor falls back to the committed placeholder atAlkitab/google-services.json.- The git commit hash is read at config time and exposed as
BuildConfig.LAST_COMMIT_HASH(consumed byAboutActivityandInstallationUtil). - The release APK is named
Alkitab-{versionCode}-{versionName}-{commitHash}-{applicationId}-{BUILD_DIST}.apk. - For non-plain release builds,
validate<Variant>FirebaseConfigreads the post-copyAlkitab/src/<flavor>/google-services.jsonand aborts the build if the API key is missing or a placeholder. debugSymbolLevel = "SYMBOL_TABLE"makes AGP emitAlkitab/build/outputs/native-debug-symbols/<variant>/native-debug-symbols.zipand embed the same symbols in the AAB, so the Play Console can symbolicate crashes in the Snappy JNI code.
The plain flavor keeps its placeholder ddd_* Bible files in Alkitab/src/plain/assets/internal/ and uses the placeholder Alkitab/google-services.json. It needs none of the proprietary env vars.
Two keystores, because org.sabda.alkitab is a separate Play listing with its
own upload key:
| Signing config | Variants | Env vars |
|---|---|---|
release |
plainRelease, yuku_alkitabRelease, yuku_quick_bibleRelease |
SIGN_KEYSTORE, SIGN_ALIAS, SIGN_PASSWORD |
releaseSabda |
sabda_alkitabRelease |
SIGN_SABDA_KEYSTORE, SIGN_SABDA_ALIAS, SIGN_SABDA_PASSWORD |
The release config is attached to the release build type, so it covers every
release variant by default. androidComponents.onVariants then overrides just
sabda_alkitabRelease with releaseSabda. Overriding per variant rather than on
the product flavor keeps sabda_alkitabDebug on the ordinary debug key.
SIGN_SABDA_* deliberately has no fallback to SIGN_*: a bundle signed with the
wrong upload key is only rejected once it reaches Play, so
validateSabda_alkitabReleaseSigningConfig fails the build up front when the
variables are missing.
./gradlew :Alkitab:signingReport prints the resolved keystore and certificate
fingerprint per variant, which is the quickest way to confirm a machine or a CI
run has both keys wired up correctly.
CI reads the keystores from repository secrets, base64-encoded because Actions secrets are text:
| Secret | Contents |
|---|---|
SIGN_KEYSTORE_BASE64 |
base64 -w0 release.jks |
SIGN_ALIAS / SIGN_PASSWORD |
alias and password for that keystore |
SIGN_SABDA_KEYSTORE_BASE64 |
base64 -w0 release-sabda.jks |
SIGN_SABDA_ALIAS / SIGN_SABDA_PASSWORD |
alias and password for the sabda keystore |
Both android.yml and release.yml decode them into $RUNNER_TEMP before the
build step and delete them afterwards in an if: always() step.
Each keystore's store password and key password have to be the same value: the
build feeds one variable to both storePassword and keyPassword.
.github/workflows/release.yml is the manual (workflow_dispatch) counterpart
to the automatic develop builds. It takes a stage (beta or release) and
an optional dry_run, and it:
- Resolves the version once via
./gradlew -q :Alkitab:printVersion, then pinsVERSION_CODEso all three flavors in the run share one code. - Fails immediately if the tag
v<versionName>already exists, which is what catches a forgottenbumpBetaNumber. - Builds signed APKs and AABs for all three production flavors, then checks
each
output-metadata.jsonagainst the version it announced. - Uploads everything as a workflow artifact with 90-day retention (rather than
the 14 days
android.ymluses), because a mapping file stays useful for as long as the build it belongs to is installed anywhere. - Creates a GitHub Release tagged
v<versionName>, marked as a pre-release for beta stages, carrying the AAB, APK,mapping.txtand native debug symbols for each flavor.
Its tags are version names (v5.0.0-beta.2), which keeps them distinct from the
versionCode-based tags (v23605150) that android.yml creates for dev
pre-releases.
Releasing a beta, end to end:
./gradlew bumpBetaNumber
git commit -am "Bump to 5.0.0-beta.2" && git push
# then run the Release workflow against that commit with stage=beta,
# download the AABs from the GitHub Release, and upload them with
# tools/play/publish.py --flavor <flavor> upload --track beta.tools/play/publish.py uploads the resulting AABs to Google Play, promotes releases between tracks, and syncs the store listing. Store metadata lives under Alkitab/src/<flavor>/play/ in the Gradle Play Publisher layout. See Google Play Publishing.
Release builds use ProGuard with:
minifyEnabled trueandshrinkResources true- No obfuscation (
-dontobfuscateinproguard-rules.pro) - Preserves: Serializable classes, OkHttp3, Gson, Kotlin Serialization, datatransfer models
Defined in Alkitab/build.gradle.kts:
SERVER_HOST:https://api.alkitab.appRIBKA_FUNCTIONS_HOST:https://us-central1-pulau-ribka.cloudfunctions.net/(release)RIBKA_FUNCTIONS_HOST_DEBUG:http://10.0.3.2:5001/pulau-ribka/us-central1/(debug, emulator localhost)
- A placeholder
Alkitab/google-services.jsonis committed soplainDebugworks out of the box. The realgoogle-services.json(covering all production applicationIds) lives at$ALKITAB_PROPRIETARY_DIR/google-services.jsonand is copied per-flavor into gitignoredAlkitab/src/<flavor>/google-services.jsonat build time — see "Release Build" above. - FCM registration is skipped in debug builds
- Firebase BOM (Messaging + Crashlytics); version pinned in
gradle/libs.versions.toml - Debug builds use
RIBKA_FUNCTIONS_HOST_DEBUGfor FCM functions
30 locales configured via androidResources.localeFilters in Alkitab/build.gradle.kts: af, bg, ceb, cs, da, de, el, es, fr, hu, in, it, ja, ko, lv, ms, my, nl, pl, pt-rBR, pt, ro, ru, th, tl, tr, uk, vi, zh-rCN, zh-rTW.