import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.google.services) alias(libs.plugins.firebase.crashlytics) alias(libs.plugins.hilt.android) alias(libs.plugins.ksp) } // Secrets resolve from local.properties (gitignored) first, then -P/gradle.properties, then env. // local.properties is NOT auto-loaded into Gradle properties, so read it explicitly here. val localProperties = Properties().apply { val file = rootProject.file("local.properties") if (file.exists()) file.inputStream().use { load(it) } } fun secret(name: String): String? = localProperties.getProperty(name)?.takeIf { it.isNotBlank() } ?: (project.findProperty(name) as? String)?.takeIf { it.isNotBlank() } ?: System.getenv(name)?.takeIf { it.isNotBlank() } android { namespace = "app.closer" compileSdk = 35 defaultConfig { applicationId = "closer.app" minSdk = 26 targetSdk = 35 versionCode = 1 versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // RevenueCat API key. Set RC_API_KEY in local.properties (never committed). // Debug builds fall back to a placeholder; release builds abort — see task guard below. buildConfigField( "String", "RC_API_KEY", "\"${secret("RC_API_KEY") ?: "PLACEHOLDER_RC_API_KEY"}\"" ) } buildFeatures { buildConfig = true compose = true } signingConfigs { create("release") { // Set these four properties in local.properties (never commit) or as environment // variables in CI. RELEASE_STORE_FILE is the path to the .jks / .keystore file, // relative to the project root. val storeFilePath = (findProperty("RELEASE_STORE_FILE") as? String)?.takeIf { it.isNotBlank() } ?: System.getenv("RELEASE_STORE_FILE")?.takeIf { it.isNotBlank() } val storePwd = (findProperty("RELEASE_STORE_PASSWORD") as? String)?.takeIf { it.isNotBlank() } ?: System.getenv("RELEASE_STORE_PASSWORD")?.takeIf { it.isNotBlank() } val keyAliasVal = (findProperty("RELEASE_KEY_ALIAS") as? String)?.takeIf { it.isNotBlank() } ?: System.getenv("RELEASE_KEY_ALIAS")?.takeIf { it.isNotBlank() } val keyPwd = (findProperty("RELEASE_KEY_PASSWORD") as? String)?.takeIf { it.isNotBlank() } ?: System.getenv("RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() } if (storeFilePath != null && storePwd != null && keyAliasVal != null && keyPwd != null) { storeFile = rootProject.file(storeFilePath) storePassword = storePwd keyAlias = keyAliasVal keyPassword = keyPwd } } } buildTypes { debug { // Stable debug token registered in Firebase Console > App Check, pre-seeded into // SharedPreferences by FirebaseInitializer so all installs use the same token. // NEVER hardcode it here — a committed token defeats App Check the moment // enforcement is enabled. Set APP_CHECK_DEBUG_TOKEN in local.properties; when // absent (e.g. CI), the field is empty and FirebaseInitializer skips seeding // (each install then mints its own token, which won't be console-registered). buildConfigField( "String", "APP_CHECK_DEBUG_TOKEN", "\"${secret("APP_CHECK_DEBUG_TOKEN") ?: ""}\"" ) } release { buildConfigField("String", "APP_CHECK_DEBUG_TOKEN", "\"\"") signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } packaging { resources.excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" } testOptions { unitTests { isReturnDefaultValues = true isIncludeAndroidResources = true } } } // Abort any release assemble/bundle task when required credentials are absent. // This runs at execution time so debug builds are never affected. tasks.matching { it.name.let { n -> (n.startsWith("assemble") || n.startsWith("bundle")) && n.contains("Release", ignoreCase = true) }}.configureEach { doFirst { val key = secret("RC_API_KEY") if (key == null || key == "PLACEHOLDER_RC_API_KEY") { throw GradleException( "RC_API_KEY is not set. Add it to local.properties or export RC_API_KEY before running a release build." ) } val storeFilePath = (findProperty("RELEASE_STORE_FILE") as? String)?.takeIf { it.isNotBlank() } ?: System.getenv("RELEASE_STORE_FILE")?.takeIf { it.isNotBlank() } if (storeFilePath == null) { throw GradleException( "Release signing is not configured. Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, " + "RELEASE_KEY_ALIAS, and RELEASE_KEY_PASSWORD in local.properties or as environment variables." ) } } } ksp { arg("room.schemaLocation", "$projectDir/schemas") } dependencies { val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) // Compose implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.extended) // Navigation implementation(libs.androidx.navigation.compose) // ViewModel + lifecycle-aware Compose state collection (collectAsStateWithLifecycle) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) // Firebase implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth.ktx) implementation(libs.firebase.firestore.ktx) implementation(libs.firebase.messaging.ktx) implementation(libs.firebase.config.ktx) implementation(libs.firebase.analytics.ktx) implementation(libs.firebase.crashlytics.ktx) implementation(libs.firebase.appcheck.ktx) implementation(libs.firebase.appcheck.playintegrity) debugImplementation(libs.firebase.appcheck.debug) // Hilt implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) implementation(libs.androidx.hilt.navigation.compose) // Room implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) // DataStore implementation(libs.androidx.datastore.preferences) // Glance — home-screen "Today" widget (content-free: daily-question state + streak only) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) // Encrypted storage implementation(libs.androidx.security.crypto) // Play Integrity API — runtime device integrity check implementation(libs.google.play.integrity) // Firebase Storage + Functions (callable for server-side integrity token verification) implementation(libs.firebase.storage.ktx) implementation(libs.firebase.functions.ktx) // RevenueCat native Android SDK implementation(libs.revenuecat.purchases) // Image loading (+ animated GIF/WebP for stickers/Bitmoji) implementation(libs.coil.compose) implementation(libs.coil.gif) // AppCompat (BiometricPrompt needs FragmentActivity) + biometric app-lock implementation(libs.androidx.appcompat) implementation(libs.androidx.biometric) // Google Sign-In via Credential Manager implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) implementation(libs.google.identity.googleid) // Coroutines implementation(libs.kotlinx.coroutines.android) // E2EE: Google Tink (AEAD) + Bouncy Castle (Argon2id KDF) implementation(libs.tink.android) implementation(libs.bouncycastle.bcprov) // Debug debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) // Unit tests — JVM only (no device/emulator required) testImplementation(libs.junit) // Reflection over the RetentionEvent sealed hierarchy (NoPlaintextInAnalyticsTest schema check) testImplementation(kotlin("reflect")) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) // Real org.json on the JVM test classpath so codec round-trips run (Android's stubbed JSONObject // returns defaults under unitTests.isReturnDefaultValues). testImplementation(libs.org.json) // Canonical-vector capture harness (paired-CI for iOS↔Android E2EE fixture fill) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) // Instrumented Compose render smoke (first-run screens) — the on-device net for the // "composes fine, crashes on first paint" class (e.g. O-ONBOARD-001). Needs the BOM so the // ui-test version matches the app's Compose; ui-test-manifest (debug, above) hosts the ComposeRule. androidTestImplementation(composeBom) androidTestImplementation(libs.androidx.compose.ui.test.junit4) }