Android client for White Noise, backed by the Marmot bindings.
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.
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, rebuilds MDK bindings
just release-fast # signed production + staging APKs, reuses checked-in bindings
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 keystoreDirect Gradle equivalents (Zapstore debug is the default local dev flavor):
./gradlew :app:testDevZapstoreDebugUnitTest :app:testDevPlayDebugUnitTest
./gradlew :app:assembleDevZapstoreDebug
./gradlew :app:installDevZapstoreDebugThe 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.
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, or
Android lint regressions. The workflow runs both the zapstore and play dev
debug variants and requires no signing secrets or google-services.json.
Pushes to master also run .github/workflows/android-instrumented.yml, a
separate emulator workflow for :app:connectedDevZapstoreDebugAndroidTest
(Zapstore debug, exercising direct-distribution config). It is
master-only, not a pull-request gate, because emulator boot is slower and more
flake-prone than the fast JVM checks. 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 checked-in Marmot bindings and requires these repository Actions
secrets:
ANDROID_GOOGLE_SERVICES_JSON_BASE64WHITENOISE_STAGING_KEYSTORE_BASE64WHITENOISE_STAGING_KEYSTORE_PASSWORDWHITENOISE_STAGING_KEY_ALIASWHITENOISE_STAGING_KEY_PASSWORDWHITENOISE_STAGING_OTLP_ENDPOINTWHITENOISE_STAGING_OTLP_AUTH_TOKENWHITENOISE_STAGING_AUDIT_LOG_ENDPOINTWHITENOISE_STAGING_AUDIT_LOG_AUTH_TOKENWHITENOISE_STAGING_TELEMETRY_TENANTWHITENOISE_STAGING_PUSH_SERVER_PUBKEY_HEXWHITENOISE_STAGING_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:lintDevZapstoreDebug :app:lintDevPlayDebug # Android lintUse 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.
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).
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 commitAlways 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.
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
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 a local smoke 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.
Dev telemetry / audit:
WHITENOISE_DEV_OTLP_ENDPOINTWHITENOISE_DEV_OTLP_AUTH_TOKENWHITENOISE_DEV_AUDIT_LOG_ENDPOINTWHITENOISE_DEV_AUDIT_LOG_AUTH_TOKENWHITENOISE_DEV_TELEMETRY_TENANT(defaults towhitenoise-android-dev)
Dev also accepts OTLP_TOKEN_WHITENOISE_ANDROID_DEV as an OTLP auth-token alias.
Dev push (MIP-05):
WHITENOISE_DEV_PUSH_SERVER_PUBKEY_HEX— dev push-server identity pubkeyWHITENOISE_DEV_PUSH_RELAY_HINT
Production telemetry / audit:
WHITENOISE_PRODUCTION_OTLP_ENDPOINTWHITENOISE_PRODUCTION_OTLP_AUTH_TOKENWHITENOISE_PRODUCTION_AUDIT_LOG_ENDPOINTWHITENOISE_PRODUCTION_AUDIT_LOG_AUTH_TOKENWHITENOISE_PRODUCTION_TELEMETRY_TENANT(defaults towhitenoise-android)
Production also accepts OTLP_TOKEN_WHITENOISE_ANDROID as an OTLP auth-token alias. Global WHITENOISE_* runtime names are accepted as production fallbacks.
Production push (MIP-05):
WHITENOISE_PRODUCTION_PUSH_SERVER_PUBKEY_HEX— push-server identity pubkeyWHITENOISE_PRODUCTION_PUSH_RELAY_HINT(defaults towss://relay.eu.whitenoise.chat)
Staging telemetry / audit:
WHITENOISE_STAGING_OTLP_ENDPOINTWHITENOISE_STAGING_OTLP_AUTH_TOKENWHITENOISE_STAGING_AUDIT_LOG_ENDPOINTWHITENOISE_STAGING_AUDIT_LOG_AUTH_TOKENWHITENOISE_STAGING_TELEMETRY_TENANT(defaults towhitenoise-android-staging)
Staging also accepts OTLP_TOKEN_WHITENOISE_ANDROID_STAGING as an OTLP auth-token alias.
Staging push (MIP-05):
WHITENOISE_STAGING_PUSH_SERVER_PUBKEY_HEX— staging push-server identity pubkeyWHITENOISE_STAGING_PUSH_RELAY_HINT
Unset push values mean the runtime treats push as unconfigured rather than registering against a default server.
app/google-services.json is optional. When present, the Firebase plugin is applied and FCM works; when absent, the app falls back to local notifications. For the supported variants it must include Android clients for dev.ipf.whitenoise.android.dev, dev.ipf.whitenoise.android.staging, and dev.ipf.whitenoise.android.
just apk-productionBuilds the signed production arm64-v8a APK using the checked-in Marmot bindings and native libraries. The output filename is whitenoise-production-v8a-release-YYYY-MM-DD.apk. The release folder is printed as the final line for Finder.
just apk-stagingBuilds the signed staging arm64-v8a APK. The output filename is whitenoise-staging-v8a-release-YYYY-MM-DD.apk.
just releaseBuilds all signed production and staging APKs (per-ABI + universal) and rebuilds the MDK/Marmot bindings. Set WHITENOISE_MDK_DIR to the MDK workspace before running this command.
just release-fastSame as just release but reuses the checked-in Marmot bindings and native libraries.
For local device checks, prefer:
just install-debugAvoid connectedDevZapstoreDebugAndroidTest on Jeff's Pixel unless he asks for it, because it can uninstall the app and wipe local state.
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.