Files
buzz/mobile/android/app/build.gradle.kts
Tom Brow 76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00

252 lines
10 KiB
Plaintext

import java.net.URI
import java.util.Base64
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val uploadKeystorePath = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH").orNull
val uploadKeystorePassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD").orNull
val uploadKeyAlias = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_ALIAS").orNull
val uploadKeyPassword = providers.environmentVariable("BUZZ_ANDROID_UPLOAD_KEY_PASSWORD").orNull
val uploadSigningValues =
mapOf(
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH" to uploadKeystorePath,
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PASSWORD" to uploadKeystorePassword,
"BUZZ_ANDROID_UPLOAD_KEY_ALIAS" to uploadKeyAlias,
"BUZZ_ANDROID_UPLOAD_KEY_PASSWORD" to uploadKeyPassword,
)
val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys
val hasUploadSigning = missingUploadSigningValues.isEmpty()
val dartDefines = providers.gradleProperty("dart-defines").orNull.orEmpty()
val pushGatewayDefinePrefix = "BUZZ_PUSH_GATEWAY_URL="
val pushGatewayOrigins =
dartDefines.split(',').mapNotNull { encoded ->
val define = runCatching { String(Base64.getDecoder().decode(encoded)) }.getOrNull()
define
?.takeIf { it.startsWith(pushGatewayDefinePrefix) }
?.removePrefix(pushGatewayDefinePrefix)
}
fun isValidPushGatewayOrigin(value: String, requireHttps: Boolean): Boolean {
val uri = runCatching { URI(value) }.getOrNull() ?: return false
val scheme = uri.scheme?.lowercase()
return (scheme == "https" || (!requireHttps && scheme == "http")) &&
!uri.host.isNullOrBlank() &&
uri.rawUserInfo == null &&
(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") &&
uri.rawQuery == null &&
uri.rawFragment == null &&
(if (requireHttps) uri.port == -1 else uri.port == -1 || uri.port in 1..65535)
}
tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach {
doFirst {
val requireHttps = !name.endsWith("Debug", ignoreCase = true)
val hasValidPushGatewayOrigin =
pushGatewayOrigins.isEmpty() ||
(pushGatewayOrigins.size == 1 &&
isValidPushGatewayOrigin(pushGatewayOrigins.single(), requireHttps))
if (!hasValidPushGatewayOrigin) {
throw GradleException(
"When supplied, BUZZ_PUSH_GATEWAY_URL must be an " +
(if (requireHttps) "HTTPS" else "HTTP(S)") +
" origin without " + (if (requireHttps) "an explicit port, " else "") +
"credentials, path, query, or fragment.",
)
}
}
}
// Worktree-aware debug identity (gitignored, written by
// scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a
// branch-labelled app name and a unique applicationId suffix so builds from
// multiple worktrees install side by side. Release builds never read this.
val worktreePropsFile = rootProject.file("worktree.properties")
val worktreeProps =
Properties().apply {
if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) }
}
// Optional gitignored developer overrides are loaded after the generated
// worktree values. They are consumed only by the debug build type below, so a
// long-lived device test build can keep a stable, descriptive local identity
// without changing release/profile or being overwritten by the worktree script.
val appOverridesFile = rootProject.file("AppOverrides.properties")
val appOverrides =
Properties().apply {
if (appOverridesFile.isFile) appOverridesFile.inputStream().use { load(it) }
}
val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() }
if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) {
throw GradleException(
"worktree.properties label must match [A-Za-z0-9._-]+ (safe for string " +
"resources), got: " + worktreeLabel,
)
}
val worktreeAppName = worktreeProps.getProperty("appName")?.takeIf { it.isNotBlank() }
if (worktreeAppName != null && !worktreeAppName.matches(Regex("""[A-Za-z0-9._() \-]+"""))) {
throw GradleException(
"worktree.properties appName must contain only letters, numbers, spaces, ., _, -, or " +
"parentheses, got: " + worktreeAppName,
)
}
val worktreeIdSuffix =
worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
val debugIdSuffix =
appOverrides.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() }
?: worktreeIdSuffix
if (debugIdSuffix != null && !debugIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) {
throw GradleException(
"debug applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
debugIdSuffix,
)
}
val debugAppName = appOverrides.getProperty("appName")?.takeIf { it.isNotBlank() }
if (
debugAppName != null &&
!debugAppName.matches(Regex("""[A-Za-z0-9][A-Za-z0-9 ._()\-]{0,39}"""))
) {
throw GradleException(
"debug appName must be 1-40 resource-safe characters, got: " + debugAppName,
)
}
// Release signing modes:
// - "upload-keystore" (default): sign with the CI-vended upload keystore;
// release builds fail loudly when any credential is missing.
// - "external": deliberately produce an UNSIGNED release bundle for a
// pipeline that signs through the central APK Signer service (Cashkite,
// BOT-1234). No keystore material may be present in this mode.
val releaseSigningMode =
providers.environmentVariable("BUZZ_ANDROID_RELEASE_SIGNING").orNull ?: "upload-keystore"
val externalReleaseSigning = releaseSigningMode == "external"
if (releaseSigningMode !in setOf("upload-keystore", "external")) {
throw GradleException(
"BUZZ_ANDROID_RELEASE_SIGNING must be \"upload-keystore\" or \"external\", got: " +
releaseSigningMode,
)
}
if (externalReleaseSigning && uploadSigningValues.values.any { !it.isNullOrBlank() }) {
throw GradleException(
"BUZZ_ANDROID_RELEASE_SIGNING=external must not be combined with " +
"BUZZ_ANDROID_UPLOAD_* credentials; unset one of them.",
)
}
android {
namespace = "xyz.block.buzz.mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "xyz.block.buzz.mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "Buzz")
}
signingConfigs {
if (hasUploadSigning) {
create("upload") {
storeFile = file(requireNotNull(uploadKeystorePath))
storePassword = uploadKeystorePassword
keyAlias = uploadKeyAlias
keyPassword = uploadKeyPassword
}
}
}
buildTypes {
debug {
// Only debug builds take the worktree identity; release/profile
// keep the production applicationId and label.
if (debugIdSuffix != null) {
applicationIdSuffix = debugIdSuffix
}
val resolvedAppName =
debugAppName
?: worktreeAppName
?: worktreeLabel?.let { "Buzz ($it)" }
if (resolvedAppName != null) {
resValue("string", "app_name", resolvedAppName)
}
}
release {
if (hasUploadSigning) {
signingConfig = signingConfigs.getByName("upload")
}
}
}
}
dependencies {
implementation("com.google.android.play:age-signals:0.0.4")
implementation("androidx.appcompat:appcompat:1.6.1")
testImplementation(kotlin("test"))
androidTestImplementation(kotlin("test"))
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:runner:1.7.0")
}
gradle.taskGraph.whenReady {
val buildsRelease = allTasks.any { task ->
task.project == project && task.name in setOf("assembleRelease", "bundleRelease")
}
if (buildsRelease && externalReleaseSigning) {
// External signing: the unsigned bundle goes to the central APK
// Signer. All keystore checks are intentionally skipped; the
// guard above already rejected any BUZZ_ANDROID_UPLOAD_* values.
return@whenReady
}
if (buildsRelease && !hasUploadSigning) {
throw GradleException(
"Release builds require Android upload signing credentials. Missing: " +
missingUploadSigningValues.sorted().joinToString(", ") +
". For central APK Signer pipelines set BUZZ_ANDROID_RELEASE_SIGNING=external.",
)
}
if (buildsRelease) {
val configuredKeystore = File(requireNotNull(uploadKeystorePath))
if (!configuredKeystore.isAbsolute) {
throw GradleException(
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be absolute: $configuredKeystore",
)
}
val keystore = file(configuredKeystore)
val repositoryRoot = rootProject.projectDir.parentFile.parentFile.canonicalFile
if (keystore.canonicalFile.toPath().startsWith(repositoryRoot.toPath())) {
throw GradleException(
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH must be outside the repository: $keystore",
)
}
if (!keystore.isFile || !keystore.canRead()) {
throw GradleException(
"BUZZ_ANDROID_UPLOAD_KEYSTORE_PATH is not a readable file: $keystore",
)
}
}
}
flutter {
source = "../.."
}