White Noise Android
September 10, 2026 · View on GitHub
Android client for White Noise, backed by the Marmot bindings.
Project Shape
The app is a Kotlin/Jetpack Compose Android app backed by the Marmot bindings. The native protocol layer owns protocol data and stores it in SQLite. The Android app should render that data, manage Android platform behavior, and keep UI lifecycle state.
The Android app should not become a second database for White Noise protocol data. If a screen is slow because a query or projection is expensive, prefer improving the native API or SQLite-backed projection over adding an Android cache.
Common Commands
just test # unit tests
just lint # ktlint check (read-only)
just format # ktlint format (rewrites in place)
just debug # build dev debug APKs
just install-debug # install dev debug on connected device
just run-debug # install + launch dev debug
just apk-production # signed production arm64-v8a APK (fast)
just apk-staging # signed staging arm64-v8a APK (fast)
just release # signed production + staging APKs with pinned MarmotKit
just release-fast # backward-compatible alias for just release
just install-production # install production arm64-v8a APK on connected device
just install-staging # install staging arm64-v8a APK on connected device
just keystore-gen # one-time release keystore generation
just keystore-fingerprint # print SHA-256 of production release keystore
just keystore-fingerprint staging # print SHA-256 of staging release keystore
Direct Gradle equivalents (Zapstore debug is the default local dev flavor):
./gradlew :app:testDevZapstoreDebugUnitTest :app:testDevPlayDebugUnitTest
./gradlew :app:assembleDevZapstoreDebug
./gradlew :app:installDevZapstoreDebug
Release-like startup and group-flow measurements, Baseline Profile generation, profile packaging verification, and Compose compiler report commands are documented in docs/performance.md. Run them on a dedicated physical device with an authenticated dev test account. The state-preserving runner replaces the dev package in place with the same debug key and avoids the connected-test teardown that otherwise removes local fixture data.
The app also has a distribution flavor dimension, orthogonal to the
environment above: zapstore enables verified direct-APK self-updates (the
Zapstore manifest permissions and the installer implementation in the zapstore
source set); play omits that installer machinery entirely and shows no
in-app update UI at all — the distributing store (e.g. Google Play) owns
updates, and Play policy forbids sending users off-store to update. Every build combines one
environment with one distribution and a build type — e.g.
assembleDevZapstoreDebug, assembleProductionPlayRelease.
The supported build matrix is intentionally small:
- dev debug:
dev.ipf.whitenoise.android.dev - staging release:
dev.ipf.whitenoise.android.staging - production release:
dev.ipf.whitenoise.android
Dev registers whitenoise-dev:// deep links, staging registers whitenoise-staging://, and production registers whitenoise://. Gradle disables production/staging debug builds and the dev release build so each bundle ID maps to exactly one intended notification environment.
Continuous Integration
Every pull request to master (and every push to master) runs the
.github/workflows/android-ci.yml validation workflow. It fails the build on
Kotlin compile errors, unit-test failures, Compose screenshot regressions
(Roborazzi — see Screenshot tests), ktlint violations,
new detekt findings, or Android lint regressions. Existing detekt findings are
frozen in config/detekt/detekt-baseline.xml. The workflow runs both the
zapstore and play dev debug variants and requires no signing secrets or
google-services.json.
Validation runs concurrently on separate runners: build/release/tooling contracts,
static analysis (ktlint, detekt, and both Android lint variants), and one test job
per distribution. Each test job compiles its dev debug variant, runs the full
unit suite, generates coverage, and then verifies screenshots. The Zapstore job
also enforces all Kover ratchets. Coverage runs before the filtered screenshot
invocation so it reuses the full unit-suite results. The existing
Compile, test, ktlint, detekt, Android lint check aggregates every job, including
the offline ZSP contract, and fails if any dependency fails, is cancelled, or skips.
Coverage artifacts are kover-coverage-report-Zapstore and
kover-coverage-report-Play. Failure reports and Gradle timing profiles use
android-ci-reports-<job> and android-ci-gradle-profiles-<job>, where <job> is
build-contracts, static-analysis, Zapstore, or Play. Separate runners
reduce the serial critical path but repeat some setup/compilation; compare both
wall time and summed job durations when measuring CI performance. Only the Play
test job writes Gradle cache state on master pushes and same-repository PRs;
build contracts, static analysis, Zapstore tests, and all fork PR jobs restore
caches read-only. This avoids publishing a separate Gradle cache state from
every parallel job.
Two security workflows run separately from the main Gradle validation so their permissions and results stay explicit:
.github/workflows/codeql.ymlcompiles the credential-free dev Zapstore and Play debug variants and scans their Java and Kotlin with CodeQL's extended security query suite. It runs for pull requests,masterpushes, a weekly full scan, and manual dispatch. Only its SARIF upload receivessecurity-events: write..github/workflows/dependency-submission.ymlsupplies GitHub's dependency graph with the resolved Gradle graph after eachmasterupdate so repository vulnerability alerts can evaluate direct and transitive Gradle dependencies. Only this submission workflow receivescontents: write.
Both security workflows pin every third-party action to a full commit SHA,
cancel superseded work, and have bounded job timeouts. CodeQL's Gradle setup
reuses the normal dependency and build caches; the dependency-submission action
manages its own Gradle execution. Dependency submission runs only from trusted
master pushes because it is the sole workflow with contents: write.
Pushes to master also run .github/workflows/android-instrumented.yml, a
separate emulator workflow for :app:connectedDevZapstoreDebugAndroidTest
(Zapstore debug, exercising direct-distribution config). Master runs the full
suite; pull requests that change MarmotKit preparation run the focused packaged
native-library smoke test. It uploads Android test reports when available and
retains them for seven days.
Pushes to master also run .github/workflows/android-staging-apk.yml, which
builds and uploads a signed arm64-v8a staging release APK for internal
installation. That workflow is also manually runnable from GitHub Actions. It
uses the checksum-verified MarmotKit artifact pinned by the repository and requires these repository Actions
secrets:
ANDROID_GOOGLE_SERVICES_JSON_BASE64WHITENOISE_STAGING_KEYSTORE_BASE64WHITENOISE_STAGING_KEYSTORE_PASSWORDWHITENOISE_STAGING_KEY_ALIASWHITENOISE_STAGING_KEY_PASSWORDWHITENOISE_OTLP_ENDPOINTWHITENOISE_STAGING_OTLP_AUTH_TOKENWHITENOISE_AUDIT_LOG_ENDPOINTWHITENOISE_AUDIT_LOG_AUTH_TOKENWHITENOISE_STAGING_PUSH_SERVER_PUBKEY_HEXWHITENOISE_PUSH_RELAY_HINT
Run the same fast checks locally before pushing:
./gradlew :app:compileDevZapstoreDebugKotlin :app:compileDevPlayDebugKotlin
./gradlew :app:testDevZapstoreDebugUnitTest :app:testDevPlayDebugUnitTest # also: just test
./gradlew :app:verifyRoborazziDevZapstoreDebug :app:verifyRoborazziDevPlayDebug # screenshot tests
./gradlew :app:ktlintCheck # style/format check (also: just lint)
./gradlew :app:detekt # code-smell/complexity check
./gradlew :app:lintDevZapstoreDebug :app:lintDevPlayDebug # Android lint
Use just format (./gradlew :app:ktlintFormat) to auto-fix ktlint findings
before re-running the check.
Dependency freshness is automated via Dependabot,
which opens grouped pull requests weekly for GitHub Actions and the
hand-pinned gradle dependencies in gradle/libs.versions.toml. Each ecosystem
caps its own open Dependabot PRs at five (the limit is per ecosystem, not
repo-wide), and GitHub Actions bumps are grouped into a single PR. Those PRs run
through the same android-ci.yml validation as any other change, so a bump
cannot merge without a green build. Compose-BOM- and Firebase-BOM-managed
artifacts (and the intentional material3 alpha pin) are excluded so Dependabot
does not fight the BOMs.
Screenshot tests
A small Roborazzi pilot guards Compose UI against visual regressions that compile cleanly and pass unit tests but ship a broken layout (issue #551). The tests render real composables on the JVM via Robolectric — no emulator — so they add no device-test runtime. The pilot covers two surfaces:
WhiteNoiseThemeScreenshotTest— a representative swatch throughWhiteNoiseThemein light, dark, and AMOLED, guarding the theme color roles (e.g. the AMOLED true-black audit, #446/#495).OnboardingContentScreenshotTest— the onboarding entry screen, light theme.
Baseline PNGs live under app/src/test/snapshots/ and are committed to git. CI
runs :app:verifyRoborazziDevZapstoreDebug and :app:verifyRoborazziDevPlayDebug; on a mismatch the build fails and the
diff/compare images are uploaded as workflow artifacts (android-ci-reports-Zapstore and android-ci-reports-Play).
Re-baseline after an intentional UI change. When you deliberately change a covered composable, regenerate the baselines and commit the updated PNGs:
./gradlew :app:recordRoborazziDevZapstoreDebug # rewrite baselines under app/src/test/snapshots/
# or :app:recordRoborazziDevPlayDebug
git add app/src/test/snapshots/ # review the image diff, then commit
Always eyeball the regenerated PNGs before committing — that review is the point
of the check. If verifyRoborazziDevZapstoreDebug or verifyRoborazziDevPlayDebug fails on a change you did not intend,
that is a caught regression: fix the UI, don't re-record.
Release Builds
The complete Android release process, store metadata, required GitHub environments, signing-key separation, and guarded publication flow are defined in docs/android-release-pipeline.md.
Production release builds use signing values from local.properties or matching environment variables:
WHITENOISE_PRODUCTION_KEYSTORE_PATHWHITENOISE_PRODUCTION_KEYSTORE_PASSWORDWHITENOISE_PRODUCTION_KEY_ALIASWHITENOISE_PRODUCTION_KEY_PASSWORD
Production also accepts global signing values as fallbacks:
WHITENOISE_KEYSTORE_PATHWHITENOISE_KEYSTORE_PASSWORDWHITENOISE_KEY_ALIASWHITENOISE_KEY_PASSWORD
Google Play release bundles use a separate upload key while Play App Signing retains the direct-distribution production key as the app-signing key:
WHITENOISE_PLAY_UPLOAD_KEYSTORE_PATHWHITENOISE_PLAY_UPLOAD_KEY_ALIASWHITENOISE_PLAY_UPLOAD_KEYSTORE_PASSWORD(local Gradle builds fall back to the production password)WHITENOISE_PLAY_UPLOAD_KEY_PASSWORD(local Gradle builds fall back to the production password)
The production build workflow requires both Play password secrets explicitly.
Run :app:bundleProductionPlayRelease -Pwhitenoise.playBundle=true separately
from APK tasks. Explicit bundle mode disables the production Zapstore variant,
selects the upload key, and disables APK splits. Resolved release APK packaging tasks
are rejected in bundle mode; abbreviated bundle tasks and clean are supported.
Staging release builds use staging-only signing values:
WHITENOISE_STAGING_KEYSTORE_PATHWHITENOISE_STAGING_KEYSTORE_PASSWORDWHITENOISE_STAGING_KEY_ALIASWHITENOISE_STAGING_KEY_PASSWORD
Release packaging fails if signing is unconfigured. To override for an unsigned, non-publishable local smoke or reproducibility build, set:
WHITENOISE_ALLOW_UNSIGNED_RELEASE=true
Runtime configuration is also read from local.properties or environment variables so endpoints and tokens stay out of Git.
Shared runtime values:
WHITENOISE_OTLP_ENDPOINT— shared by dev, staging, and production.WHITENOISE_AUDIT_LOG_ENDPOINT— shared by staging and production.WHITENOISE_AUDIT_LOG_AUTH_TOKEN— shared by staging and production, separate from OTLP auth.WHITENOISE_PUSH_RELAY_HINT— shared by staging and production (production defaults towss://relay.eu.whitenoise.chat).
Flavor-specific OTLP tokens:
WHITENOISE_DEV_OTLP_AUTH_TOKENWHITENOISE_STAGING_OTLP_AUTH_TOKENWHITENOISE_PRODUCTION_OTLP_AUTH_TOKEN
The token selects the telemetry tenant. There is no shared token fallback.
Legacy token aliases OTLP_TOKEN_WHITENOISE_ANDROID_DEV,
OTLP_TOKEN_WHITENOISE_ANDROID_STAGING, and OTLP_TOKEN_WHITENOISE_ANDROID
remain accepted for their respective flavors.
No tenant secret is read. MarmotKit requires a nonempty tenant resource attribute,
so Android supplies fixed compatibility values (whitenoise-android,
whitenoise-android-staging, and whitenoise-android-dev). HTTP authentication
and tenant routing use the bearer token. The separate deployment environment
attribute remains production, staging, or development, respectively.
Push identities (MIP-05):
WHITENOISE_STAGING_PUSH_SERVER_PUBKEY_HEX— staging push-server identity.WHITENOISE_PRODUCTION_PUSH_SERVER_PUBKEY_HEX— production push-server identity (WHITENOISE_PUSH_SERVER_PUBKEY_HEXremains a production fallback).
Dev audit and push:
Dev does not inherit shared Goggles credentials or staging/production push identities. Explicit dev configuration remains available:
WHITENOISE_DEV_AUDIT_LOG_ENDPOINTWHITENOISE_DEV_AUDIT_LOG_AUTH_TOKENWHITENOISE_DEV_PUSH_SERVER_PUBKEY_HEXWHITENOISE_DEV_PUSH_RELAY_HINT
Without these values dev uploads and push registration are unconfigured. Local audit recording is separate. Dev OTLP needs its own token as well as the shared endpoint. Preview keeps telemetry, audit, and push configuration empty.
app/google-services.json is optional for dev, preview, and explicitly unsigned
reproducibility builds. Signed production packaging requires the file and an
Android client for dev.ipf.whitenoise.android; scripts/release.sh also
verifies that the final APK contains google_app_id and
gcm_defaultSenderId. When present for other supported variants, the file
should also include clients for dev.ipf.whitenoise.android.dev and
dev.ipf.whitenoise.android.staging.
Building a release
just apk-production
Builds the signed production arm64-v8a APK using the immutable MarmotKit
artifact pinned in app/src/main/marmotkit/MARMOT_VERSION. Gradle downloads it
once, verifies its checksum, provenance, layout, and native architectures, and
reuses the content-addressed cache afterward. The output filename is
whitenoise-production-v8a-release-YYYY-MM-DD-<sha>.apk. The release folder is
printed as the final line for Finder.
To build the direct APK and Play AAB once, verify both signing identities and all ABI/version invariants, and collect the release manifest, checksums, release notes, and store assets together, run:
just production-release <version>
The manual Android Production Build workflow creates the candidate without publishing. A separate workflow distributes reviewed artifacts to a GitHub draft or Play internal testing. Public Zapstore publication requires its own workflow, version-specific confirmation, and protected-environment approval.
The verified bundle is written to build/production-release/. Zapstore and
GitHub must distribute the exact APK from that directory; rebuilding or
re-signing it per destination defeats the cross-store update contract.
just apk-staging
Builds the signed staging arm64-v8a APK. The output filename is
whitenoise-staging-v8a-release-YYYY-MM-DD-<sha>.apk.
just release
Builds all signed production and staging APKs (per-ABI + universal) with the
same pinned MarmotKit artifact. No local MDK checkout or NDK binding rebuild is
required. After one successful preparation, Gradle --offline builds reuse the
verified artifact from the Gradle user-home cache. Set
WHITENOISE_MARMOTKIT_CACHE_DIR only when an explicit shared cache location is
needed.
For an air-gapped first build, provide the exact pinned ZIP with
-Pwhitenoise.marmotkit.artifactFile=/path/to/marmotkit-android-0.9.12.zip
or WHITENOISE_MARMOTKIT_ARTIFACT_FILE; the same checksum and provenance checks
still apply.
Python 3 is required for artifact preparation. Gradle selects python3 on
Unix-like hosts and python on Windows; set WHITENOISE_PYTHON to override the
executable name or path.
Maintainers updating the pinned release should follow docs/updating-marmotkit.md. The lock, generated API signature, and consumer/native checks must move together.
just release-fast
Backward-compatible alias for just release; both use the same cached,
checksum-verified artifact path.
Reproducible unsigned release APK (#1261)
To verify that two isolated builds of the same commit produce identical unsigned
production Zapstore arm64-v8a release APK bytes (no signing secrets, no
google-services.json), see docs/reproducible-apk-builds.md
and run ./scripts/repro-verify.sh. Tag/manual CI runs publish matching
artifacts plus the recorded toolchain from
.github/workflows/android-repro-verify.yml.
Device Testing
Release candidates are tested with the stable, numbered manual release testing checklist. Use its permanent test IDs in every failure report so results remain actionable across releases.
For local device checks, prefer:
just install-debug
Avoid connectedDevZapstoreDebugAndroidTest on Jeff's Pixel unless he asks for it, because it can uninstall the app and wipe local state.
Performance Guidance
Keep Compose work cheap. Do not call slow binding, database, or network paths from composition or from the main thread.
Use White Noise streams and SQLite-backed projections as the fast path. If Android needs a shape that is expensive to assemble, add or improve the native projection rather than storing a duplicate copy in the Android app.
Close native subscriptions when screens or services stop using them.