Skip to content

Build & Tooling

How the project is built, kept in style, guarded against regressions, and shipped. For the module graph and the two-layer design these tools build on top of, see Architecture.

Command reference

Run these commands from the repository root. Start with the JDK 21 and Android SDK setup in Getting Started. Gradle downloads missing dependencies on the first run.

Task Command Prerequisite / result
Build debug APK ./gradlew assembleDebug app/build/outputs/apk/debug/app-debug.apk
Install debug app ./gradlew installDebug Connected device or running emulator; launch the app afterward
Unit tests ./gradlew test Module reports under <module>/build/reports/tests/; includes Robolectric tests
One module's unit tests ./gradlew :feature:home:testDebugUnitTest HTML report in feature/home/build/reports/tests/testDebugUnitTest/
Device tests ./gradlew connectedAndroidTest Device/emulator visible to adb devices; reports under <module>/build/reports/androidTests/connected/
Custom lint tests ./gradlew :lint:test Report in lint/build/reports/tests/test/
Android lint ./gradlew lint Reports under <module>/build/reports/
Convention plugin checks ./gradlew :build-logic:convention:check Checks the included build's own code
Format / check format ./gradlew spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache / ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache See Spotless for scope
API documentation ./gradlew dokkaGeneratePublicationHtml build/dokka/html/index.html
Dependency baseline check ./gradlew :app:dependencyGuard Compares release runtime dependencies with app/dependencies/releaseRuntimeClasspath.txt
APK badging check ./gradlew :app:checkReleaseBadging Builds release APK and compares badging with app/release-badging.txt
Baseline profile / benchmark See Baseline profiles Managed emulator and SDK system image required
Signed release APK / AAB ./gradlew assembleRelease / ./gradlew bundleRelease Configure release signing; outputs in app/build/outputs/apk/release/ / app/build/outputs/bundle/release/

Regenerate guard files only after reviewing an intentional dependency or manifest change; the guardrail sections explain the update tasks. Use a focused module task while iterating; a routine build does not require clean.

Jenkins verification

Jenkinsfile runs just verify and archives its results. GitHub Actions continues to handle publication; Jenkins does not deploy documentation, upload releases, or update dependency baselines. A release APK built by verification can use the debug signing fallback, so only the debug APK is archived.

Controller and agent setup

Install the Jenkins Pipeline (including Declarative Pipeline), Git, and JUnit plugins. Create a Pipeline job using Pipeline script from SCM, select this repository and the desired branch, and leave the script path as Jenkinsfile. A Multibranch Pipeline also works; configure branch/PR discovery in Jenkins. The Jenkinsfile does not configure webhooks or polling. Use read-only SCM credentials if the repository needs authentication; no GitHub publishing credential is required.

Provide a dedicated Unix agent with label android and these tools on the agent process's PATH:

  • JDK 21 (JAVA_HOME must match), Git, Bash, and just.
  • Python 3.14 available as python3.14, with venv and pip support.
  • Android SDK packages matching the version catalog and Android Gradle Plugin, including the compile SDK platform and build tools. Set ANDROID_HOME (or ANDROID_SDK_ROOT) in the agent environment and accept the SDK licenses before running the job.

Allow network access to the repository, Gradle distributions, JVM toolchain downloads, Maven repositories, and PyPI. The wrapper controls Gradle's version; the checked-in daemon criteria select JetBrains JDK 21. Provision enough memory for the CI Gradle configuration's 4 GiB heap plus compiler, test, and Jenkins agent processes. No emulator, release signing key, or private Firebase configuration is needed for this verification job.

Each build clears its workspace before checkout, creates an isolated Gradle user home under .gradle/ci-home, copies the existing CI properties there, and installs the existing documentation packages into .venv. It does not modify the agent user's global Gradle properties. This prioritizes clean inputs over cache reuse, so cold builds download their dependencies. Builds of the same job run sequentially, have a 120-minute timeout after agent allocation, and retain the latest ten builds.

Results and validation

The Verify stage uses the same just verify command as local development, including real Dokka, strict MkDocs, and documentation link validation. A failed check fails the build. Available Gradle JUnit XML and lint reports are archived even on failure, including reports from the included build-logic build. JUnit results appear in Jenkins's test report; missing results fail an otherwise successful build. Successful builds require and archive app/build/outputs/apk/debug/app-debug.apk and the validated site/ tree. No HTML publishing plugin is required; download the site artifact to inspect it locally.

Before running a changed pipeline, use the controller's Declarative Pipeline linter. For an authenticated controller, provide a username/API-token pair through JENKINS_AUTH in your terminal environment, set JENKINS_URL, and run:

curl --fail --user "$JENKINS_AUTH" --form 'jenkinsfile=<Jenkinsfile' "$JENKINS_URL/pipeline-model-converter/validate"

Require the response to say Jenkinsfile successfully validated.; HTTP success alone does not mean that the pipeline is valid. Then build the candidate revision in a disposable job using a prepared android agent. Confirm that verification succeeds, test results are recorded, and both the debug APK and site artifacts exist. In a disposable checkout, force just verify to fail after writing a test report and confirm that Jenkins fails the build while retaining diagnostics. A linter pass alone does not establish that checkout, tools, tests, or artifact collection work.

Convention plugins

Every module applies build configuration through one of seven convention plugins in build-logic/convention/src/main/kotlin/, registered as dev.atick.* Gradle plugin IDs and aliased in gradle/libs.versions.toml (jetpack-application, jetpack-library, etc.):

Plugin class ID Applies to What it configures
ApplicationConventionPlugin dev.atick.application app com.android.application, Compose compiler, oss-licenses, kotlinx-serialization, Dependency Guard, APK badging tasks, Kotlin/Compose/lint/test config
LibraryConventionPlugin dev.atick.library Non-UI modules (data, sync, core/room, ...) com.android.library, kotlinx-serialization, resource prefixing, Kotlin/lint/test config
UiLibraryConventionPlugin dev.atick.ui.library Compose modules (core/ui, feature/*) Everything library does, plus the Compose compiler
TestConventionPlugin dev.atick.test benchmarks com.android.test, Gradle Managed Devices, Kotlin config
DaggerHiltConventionPlugin dev.atick.dagger.hilt Modules using Hilt DI com.google.dagger.hilt.android, KSP
FirebaseConventionPlugin dev.atick.firebase firebase/* google-services, Crashlytics
DokkaConventionPlugin dev.atick.dokka Every documented module org.jetbrains.dokka

Shared logic each plugin composes lives alongside them as top-level functions, not plugins themselves: configureKotlinAndroid, configureAndroidCompose, configureAndroidLint, configureAndroidTest, configureResourcePrefix, configureBadgingTasks, configureGradleManagedDevices.

Apply or extend the build configuration

A Compose feature module applies the existing UI, Hilt and documentation aliases, as feature/home/build.gradle.kts does:

plugins {
    alias(libs.plugins.jetpack.ui.library)
    alias(libs.plugins.jetpack.dagger.hilt)
    alias(libs.plugins.jetpack.dokka)
}

Use jetpack.library for a non-UI Android library. These aliases resolve to the convention plugins above; module-specific configuration still belongs in the module's android and dependencies blocks. Follow the feature guide for module registration and dependencies.

For an approved new dependency, add its version to [versions] and alias to [libraries] in gradle/libs.versions.toml, then consume the generated accessor:

// Existing aliases in core/network/build.gradle.kts:
dependencies {
    implementation(platform(libs.okhttp.bom))
    implementation(libs.okhttp.core)
}

A BOM supplies versions for its member libraries; do not assign competing member versions. For a new external Gradle plugin, add a [plugins] alias. The catalog owns library and plugin versions used through these aliases; the Spotless init script and GitHub workflows also contain explicit tool versions. Editing prose does not move or update them.

.github/renovate.json extends config:recommended, group:all, :dependencyDashboard, and schedule:daily. When adopting the template, enable the Renovate GitHub App for your repository and review its onboarding/dashboard and grouped updates. No Dependabot configuration is supplied. CI commits the regenerated Dependency Guard baseline to Renovate PRs (see Renovate PRs), and gitIgnoredAuthors lists the bot authors of that commit so Renovate keeps rebasing the branch.

Discuss new dependencies, modules, convention-plugin changes, SDK/compiler upgrades and formatting configuration changes before implementing them. To extend a convention plugin after agreement, edit its class/shared helpers in build-logic/convention/src/main/kotlin/; a new plugin also needs registration in build-logic/convention/build.gradle.kts and a catalog alias. Run the convention checks and affected consumer build afterward. Root-relative paths in shared build logic use isolated.rootProject.projectDirectory to support Isolated Projects.

Compose compiler stability config

compose_compiler_config.conf (repo root) lists types the Compose compiler should treat as stable even though it can't prove it — currently just dev.atick.data.model.**, the data layer's immutable models:

// Only add types that really are immutable — an entry here is a promise the compiler trusts
// without checking.
dev.atick.data.model.**

Every Compose module reads this file through AndroidCompose.kt (stabilityConfigurationFiles.add(...)). The configuration lets the compiler treat matching types as stable across module boundaries; it does not verify their immutability.

Code quality

Spotless, via an init script

Spotless is applied by gradle/init.gradle.kts when that init script is passed explicitly. Without it, the normal build does not register Spotless tasks. With it, the script applies the plugin to subprojects and defines these targets relative to each project's directory:

Target Files License header Ruleset
kotlin **/*.kt spotless/copyright.kt ktlint 1.6.0, android=true, Compose rules 0.6.3
groovy **/*.gradle spotless/copyright.gradle Header insertion
kts **/*.kts spotless/copyright.kts Header insertion
xml **/*.xml spotless/copyright.xml Header insertion

Each target excludes files under build/. Markdown is not a target, and the root project's own files are outside the subprojects block. The root spotlessCheck invocation checks main-build subprojects, including intermediate grouping projects; it does not run the included build's checks.

Task discovery works when the init script is present: ./gradlew tasks --all --init-script gradle/init.gradle.kts --no-configuration-cache. The included build also registers Spotless tasks when addressed explicitly, but its check currently fails: header paths resolve to the missing build-logic/spotless/copyright.* templates. A successful root check therefore does not establish formatting coverage for convention-plugin sources. Fixing that init-script limitation is a separate build configuration change.

./gradlew spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache
./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache

To personalize copyright headers, edit the holder/year in all four spotless/copyright.* templates, run the apply command, and review the resulting source diff before checking it. The XML template is an XML comment; the Kotlin/Gradle templates are block comments. Preserve those wrappers and the init script's header delimiters. Changing targets or rules needs separate agreement.

Custom lint (:lint)

Two checks, implemented as a plain java-library module (not a convention-plugin consumer — lint tooling targets an older JVM) and shipped to every consumer automatically:

Check File Severity Flags
DesignSystemDetector lint/.../designsystem/DesignSystemDetector.kt ERROR Raw Material3 composables where core/ui provides a wrapper
TestMethodNameDetector lint/.../TestMethodNameDetector.kt WARNING @Test methods named test* (the annotation already says it's a test)

Both are registered in JetpackIssueRegistry and reach every module through lintPublish(projects.lint) in core/ui/build.gradle.kts:78 — a module using the design system gets the checks without wiring anything up itself.

Run the check's own unit tests: ./gradlew :lint:test.

Build guardrails

APK badging

Badging.kt registers three tasks per application variant, comparing aapt2 dump badging output against a committed golden file so a permission, SDK-version, or manifest change is caught in review instead of at release time:

Task (release variant) Does
:app:generateReleaseBadging Dumps badging info from the built APK
:app:checkReleaseBadging Diffs it against app/release-badging.txt, fails the build on drift
:app:updateReleaseBadging Overwrites app/release-badging.txt with the new output

CI runs checkReleaseBadging on every PR (.github/workflows/ci.yml); update the golden file locally with updateReleaseBadging when a change is intentional.

The badging check builds a release APK and can also schedule Crashlytics mapping upload through the release build. For local validation without that upload task, run ./gradlew :app:checkReleaseBadging -x :app:uploadCrashlyticsMappingFileRelease. Passing this check does not establish that the APK uses a production signing key.

Dependency Guard

Fails the build when the release runtime classpath drifts from a committed baseline, so a new transitive dependency has to be reviewed rather than arriving unnoticed:

// Regenerate with: ./gradlew :app:dependencyGuardBaseline
dependencyGuard {
    configuration("releaseRuntimeClasspath")
}

Baseline lives at app/dependencies/releaseRuntimeClasspath.txt. ./gradlew :app:dependencyGuard checks it (also run in CI); ./gradlew :app:dependencyGuardBaseline regenerates it.

Renovate PRs

The hosted Renovate app cannot run Gradle, so its version bumps never update the baseline. On a pull request opened by renovate[bot] from this repository, the build job in ci.yml regenerates the baseline when the check fails, commits it to the PR branch as 📌 chore(deps): update Dependency Guard baseline [no-task] [skip ci], and runs the remaining checks against it. This follows Now in Android. Every other run, including your own PRs, fork PRs and repository_dispatch, still fails on drift.

  • The bot commit has no checks of its own, because of [skip ci]. Without it, GitHub starts a run for the bot's push and holds it until someone approves it. Renovate rewrites its branch almost daily, which drops the bot commit, so the approval prompt would return every day. The run that made the commit already tested the regenerated baseline. If 🏗️ Build & Test is a required check, merge these PRs as an admin.
  • That job checks out the PR branch (github.head_ref) on Renovate PRs, because git-auto-commit-action cannot push from the default merge ref. Keep that ref: line.
  • APK badging is not updated automatically. A bump that changes merged-manifest permissions still fails APK badging until someone reviews and updates the golden file.

Gradle Managed Devices

GradleManagedDevices.kt registers two virtual devices Gradle can boot and tear down itself, so instrumented tests and baseline-profile generation don't depend on whatever's plugged in:

Device API Image Task prefix
Pixel 6 33 aosp pixel6Api33
Pixel 4 30 aosp-atd (smaller, automated-test-device) pixel4Api30

dev.atick.test registers these devices in :benchmarks. Its baseline-profile configuration selects pixel6Api33 and disables connected devices. API 33 is the configured generation environment; this is not a minimum API requirement for apps consuming profiles. See the current profile generation requirements. Install the corresponding SDK emulator/system image and enable hardware virtualization. Gradle can download missing device components; running these tasks boots an emulator.

Baseline profiles

:benchmarks (BaselineProfileGenerator.kt, StartupBenchmark.kt) generates and measures the app's baseline profile, wired via alias(libs.plugins.baselineprofile) in app/build.gradle.kts:

// app/build.gradle.kts:161-167
baselineProfile {
    automaticGenerationDuringBuild = false
    dexLayoutOptimization = true
}

Important

automaticGenerationDuringBuild = false means the profile is not regenerated automatically — every build would otherwise wait for an emulator. Run ./gradlew :app:generateReleaseBaselineProfile by hand whenever the app's startup path changes.

Generate the startup profile, then measure the two compilation modes in StartupBenchmark:

./gradlew :app:generateReleaseBaselineProfile
./gradlew :benchmarks:pixel6Api33BenchmarkReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=dev.atick.benchmarks.StartupBenchmark

The class filter keeps the measurement run separate from BaselineProfileGenerator. Generation writes profile sources under app/src/release/generated/baselineProfiles/; review those files when regenerating. Managed-device HTML results are under benchmarks/build/reports/androidTests/managedDevice/; benchmark JSON/traces are in the corresponding benchmarks/build/outputs/managed_device_android_test_additional_output/ tree. Compare timeToInitialDisplayMs for startupNoCompilation and startupWithBaselineProfile; the latter requires a packaged profile. Emulator results help check the harness; use a physical device for representative performance measurements. This generator covers app startup only.

Compose compiler metrics and reports

Compile a UI module with both opt-in properties:

./gradlew :feature:home:compileReleaseKotlin -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true

AndroidCompose.kt enables each destination only when its property is true. Outputs are relative to the root build directory, retaining the module path: this command writes to build/feature/home/compose-metrics/ and build/feature/home/compose-reports/, not feature/home/build/. Other UI modules use the same layout. If an up-to-date compile has no output, repeat the focused command with --rerun-tasks.

Read the metrics JSON for aggregate counts, the composables text/CSV reports for inferred stability and restartable/skippable functions, and the classes report for unstable fields. Compare reports from the same variant before and after a change. These are compiler decisions, not runtime timings; use Android Studio's Layout Inspector for recomposition inspection and CPU/System Trace profiling for measured work. To inspect app size, build a release APK and open it in Studio's APK Analyzer. Release shrinking is already enabled; investigate release-only failures with Logcat and app/build/outputs/mapping/release/mapping.txt before changing keep rules.

Local dev

.run/ configurations

13 IntelliJ/Android Studio run configurations, each a thin wrapper over one Gradle task:

Configuration Task
Run Unit Tests test
Run Instrumentation Tests connectedAndroidTest
Run Custom Lint Tests :lint:test
Check Convention Plugins :build-logic:convention:check
Spotless Check spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
Spotless Apply spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache
Check APK Badging :app:checkReleaseBadging
Update APK Badging :app:updateReleaseBadging
Check Dependency Guard :app:dependencyGuard
Update Dependency Guard Baseline :app:dependencyGuardBaseline
Generate Baseline Profile :app:generateReleaseBaselineProfile
Generate Docs :dokkaGeneratePublicationHtml
Signing Report signingReport

Local build configuration

The REST client reads BuildConfig.BACKEND_URL. Supply BACKEND_URL=https://your-backend.example/ in root local.properties when adopting a backend, with a trailing slash as Retrofit requires. The secrets plugin uses secrets.defaults.properties as its fallback file; see Firebase setup for the separate GOOGLE_WEB_CLIENT_ID input. These values become build configuration, so do not use them to embed server credentials in the app.

Gradle build performance

Set in gradle.properties: org.gradle.parallel=true, org.gradle.caching=true, org.gradle.configuration-cache=true (with .parallel=true), and org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g.

Develocity build scans (settings.gradle.kts:47-56) publish only when a CI environment variable is set, so automatic publication is disabled when CI is absent or empty. A nonempty value such as CI=false still satisfies that predicate; an explicit --scan request is a separate choice.

CI/CD

Workflow Trigger Does
ci.yml (🚀 Build & Validate) Pull request; repository_dispatch type libs-versions-updated lint job: Spotless check, :build-logic:convention:check. build job: Dependency Guard (regenerated and committed on Renovate PRs), unit tests, :lint:test, project build, Android Lint (uploaded to Code Scanning), APK badging check
cd.yml (🚀 Release APK) Tag push v*.*.* lint → build (assembles the signed release) → github-release and play-store-release (Fastlane)
docs.yml (📚 Documentation Validation & Deployment) Pull request; push to main Validates real API/site output; a separate main-only job deploys the validated artifact
claude.yml (Claude Code) Issue/PR comments and reviews Claude Code GitHub Action automation — not part of the build pipeline

Release

Prepare the application and signing

  1. Set the adopted application's ID in app/build.gradle.kts and match it in Firebase, Play Console and fastlane/Appfile. The supplied Appfile still names dev.atick.compose.
  2. Update majorUpdateVersion, minorUpdateVersion, and patchVersion in the app build file. The current formula is major * 10000 + minor * 100 + patch; verify that the resulting versionCode exceeds every version already uploaded to Play. A Git tag does not set the version.
  3. Follow release signing setup. Keep the established signing/upload key for updates. For the workflow's app/key.jks, set storeFile=key.jks in the root signing properties: the build resolves this path relative to app, not the repository root.
  4. Build and test the signed release locally, review CHANGELOG.md, and run the checks listed in Contributing. Keep signing material and service-account keys out of commits.

Caution

Without keystore.properties, the release build falls back to the debug key. Build success alone does not establish that an APK or AAB is correctly signed for publication.

Play access and Bundler

Use a managed Ruby environment with Bundler; the release workflow currently selects Ruby 4.0.6, while Gemfile.lock records Bundler 2.7.2 and Fastlane 2.237.0. From the repository root:

bundle install

Use bundle exec for later Fastlane commands so they use the lockfile. Do not run fastlane init over this repository's existing Appfile/Fastfile. See Fastlane installation for Ruby/Bundler setup.

For Play access, create the app in Play Console and complete an initial manual build upload before using Supply. Enable the Google Play Developer API in a Cloud project, create a service account, and invite its email in Play Console's Users and permissions with access to the intended app and release track. Google no longer requires linking the developer account to a Cloud project; follow Google's API setup for current account steps. This workflow uses a downloaded service-account JSON key at fastlane/play-store.json; Firebase's google-services.json is a different file.

The Appfile uses this shape; replace its supplied dev.atick.compose package with your adopted ID. Run Fastlane from the repository root so the key path resolves correctly:

json_key_file("fastlane/play-store.json")
package_name("your.application.id")

After supplying your key and matching package name, these commands validate access and fetch the existing listing; they do not publish a release:

bundle exec fastlane run validate_play_store_json_key json_key:fastlane/play-store.json
bundle exec fastlane supply init

Supply downloads metadata into fastlane/metadata/android/<locale>/. Review titles/descriptions there and add images/screenshots explicitly; the initial download does not retrieve existing screenshots or videos. See Supply setup and metadata.

Repository secrets

In the adopted GitHub repository's Settings → Secrets and variables → Actions, add these repository secrets. The table follows .github/workflows/cd.yml; use your own files and values.

Secret Stored format Workflow destination
GOOGLE_SERVICES Base64 of Android Firebase configuration JSON app/google-services.json
KEYSTORE Base64 of the binary signing keystore app/key.jks
KEYSTORE_PROPERTIES Base64 of signing properties (storeFile=key.jks, storePassword, keyAlias, keyPassword) Root keystore.properties
GOOGLE_WEB_CLIENT_ID Plain web OAuth client ID GOOGLE_WEB_CLIENT_ID=… in root local.properties
PLAY_STORE_JSON Base64 of the Play service-account JSON key fastlane/play-store.json in the Play job

To encode a file on macOS or Linux without wrapped lines, use the following pattern locally with your own input/output paths, then paste the output into the matching secret:

base64 < /path/to/input-file | tr -d '\r\n' > /path/to/encoded-file

Repeat for the four encoded inputs; paste the web client ID directly. Base64 is an encoding, not encryption: treat encoded copies like the originals. The workflow decodes those four inputs and writes the client ID as text. Both build jobs reconstruct their own signing/Firebase inputs; the Play job additionally reconstructs the service-account key.

Lanes, metadata, and publication

Lane Current behavior
test Runs Gradle test
beta Legacy lane calling assembleRelease then crashlytics; not a working distribution setup for the locked Fastlane version
deploy Builds an AAB with bundleRelease, generates English release notes from five commits, then calls upload_to_play_store(validate_only: false)

The legacy Crashlytics Beta action is not present in Fastlane 2.237.0. Firebase Crashlytics crash reporting handles crash reports; a future distribution workflow would need separate configuration, such as Firebase App Distribution. Do not use the supplied beta lane as release instructions.

deploy overwrites fastlane/metadata/android/en-US/changelogs/default.txt. Hand-editing that file before running the lane will lose the edits. Supply prefers <versionCode>.txt over default.txt for that version; other locale notes and listing assets also need review. Changelog files are currently gitignored, so local notes will not reach a tag checkout unless you deliberately change how they are supplied. The release checkout also uses the default shallow history: the lane's five-commit request does not guarantee five commits are available there.

Warning

The supplied deploy lane performs a real upload. With no track/status override, the locked Supply defaults are production and completed. Metadata and images are uploaded too when supplied. Choose and configure your release policy before triggering this workflow; the lane is not a validation-only or internal-testing lane.

Once the adopted repository is configured and the release is reviewed, create a version tag such as v1.3.1 on that revision and push that tag to trigger cd.yml. Tag publication is an explicit release action. The workflow then:

  1. Runs formatting validation and builds a signed APK.
  2. Starts two separate jobs after the APK build: github-release attaches that APK and uses the whole root CHANGELOG.md as the release body; play-store-release rebuilds an AAB with Fastlane and uploads it to Play. Neither publication job waits for the other to succeed.

For a deliberate local Play publication with the same configured lane, the command is bundle exec fastlane android deploy. Generated fastlane/README.md lists lane commands; this section supplies the setup and consequences.

If a release fails, inspect the failed job first. Base64/JSON errors point to secret format; signing errors to the decoded properties, app-relative key path or alias; Play permission errors to API enablement and app access; duplicate-version errors to versionCode. Check listing/draft-app requirements in Play Console for metadata rejection. The APK build has a 45-minute timeout; a successful GitHub release does not establish that Play publication succeeded.

Local documentation preview

Run from the repository root after completing Android/JDK setup. Python 3.14 matches docs.yml. The packages below mirror CI; only the admonitions plugin is pinned.

python3 -m venv .venv
.venv/bin/python -m pip install mkdocs mkdocs-material mkdocs-github-admonitions-plugin==0.1.1 Pygments
./gradlew dokkaGeneratePublicationHtml -PdocsRevision="$(git rev-parse HEAD)"
.venv/bin/python -m unittest discover -s scripts -p 'test_*.py'
.venv/bin/python scripts/prepare_docs.py
.venv/bin/python -m mkdocs build --strict --config-file build/docs-mkdocs.yml
.venv/bin/python scripts/check_docs.py --site site
.venv/bin/python -m mkdocs serve --config-file build/docs-mkdocs.yml

Open the server URL printed in the terminal (normally http://127.0.0.1:8000/) and stop it with Ctrl+C. Home is available at /, Dokka at /api/, and the benchmark and custom-lint references under Tooling. Run preparation and the strict build again after changing documentation; serve watches the assembled copy rather than the authored guides.

prepare_docs.py requires real build/dokka/html/index.html; it does not create placeholder API pages. It replaces only build/docs-source/, copies authored guides and actual API output there, and generates the README homepage and tooling references. The generated configuration is build/docs-mkdocs.yml. Obsolete API files cannot survive a fresh preparation.

The homepage transformation adjusts Markdown link destinations, preserving external URLs and fragments. Repository-only links and generated source links point to the documented commit. Preparation defaults to HEAD; --revision <full-commit-sha> and Gradle's matching -PdocsRevision=<full-commit-sha> select a different revision. Uncommitted content can be previewed, but remote source links describe the selected commit, not local edits. Historical evidence pins remain unchanged. Module READMEs use absolute main URLs because Dokka treats relative Markdown destinations in those introductions as unresolved references. Preparation rewrites active main links to the selected commit in the assembled site. Use that preview for unmerged documentation; repository README links follow the released guides until merge.

Dokka publishes seventeen application/support modules, including navigation and testing. Each module includes its README introduction. API pages show declared members; superclass links lead to inherited SDK API references. Benchmark and lint READMEs are guide references rather than extra API modules, so documenting them does not change their build configuration. Dokka warnings fail generation. MkDocs validates authored page links and anchors; check_docs.py also checks root/module references and assembled HTML destinations, images, assets and fragments. External URL availability and visual usability still require review.

The thirteen retired guide paths have compatibility pages. Old heading anchors land on those pages, which link to maintained replacements; they do not restore obsolete instructions. The historical homepage URL remains /. Compatibility pages outside navigation remain published.

Pull requests and every push to main, including workflow-only changes, run the same preparation, strict build and link checks. CI validates the PR head commit; it uploads the site as an artifact. A separate deployment job runs only after successful main-push validation and publishes that artifact without rebuilding. Pull-request validation has read-only repository permissions. Local preview commands do not deploy anything.

build/, site/ and .venv/ are ignored. The older generated paths docs/index.md and docs/api/ also remain ignored but are not inputs to the new preparation process. Authored compatibility pages in docs/*.md remain tracked. Do not commit generated output or local environments.

Further reading