apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" abstract class VerifyD3roMobileVariantConfiguration extends DefaultTask { private static final String GOOGLE_TEST_ADMOB_PUBLISHER_ID = "ca-app-pub-3940256099942544" private static final java.util.regex.Pattern STRICT_SEMVER = java.util.regex.Pattern.compile( /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ ) @org.gradle.api.tasks.Input abstract org.gradle.api.provider.Property getVariantName() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getVersionNameSetting() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getVersionCodeSetting() @org.gradle.api.tasks.Input abstract org.gradle.api.provider.ListProperty getMissingReleaseSettings() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getFirebaseConfigPath() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getReleaseStoreFilePath() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getProductionAdMobAppId() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getProductionBannerUnitId() @org.gradle.api.tasks.Input @org.gradle.api.tasks.Optional abstract org.gradle.api.provider.Property getProductionRewardedUnitId() @org.gradle.api.tasks.TaskAction void verifyConfiguration() { def versionName = versionNameSetting.orNull def versionCode = versionCodeSetting.orNull if (versionName == null || versionName.trim().isEmpty()) { throw new GradleException("D3RO_VERSION_NAME is required for release and e2e builds") } if (versionName.length() > 100 || !STRICT_SEMVER.matcher(versionName).matches()) { throw new GradleException("D3RO_VERSION_NAME must be a strict semantic version") } if (versionCode == null || !(versionCode ==~ /[1-9]\d{0,9}/)) { throw new GradleException("D3RO_VERSION_CODE must be a positive decimal integer") } if (versionCode.toLong() > 2100000000L) { throw new GradleException("D3RO_VERSION_CODE exceeds the Android maximum") } if (variantName.get() != "release") { return } if (!new File(firebaseConfigPath.get()).isFile()) { throw new GradleException( "Release build is blocked. Add android/app/google-services.json for com.d3ro.voice" ) } def missingSettings = missingReleaseSettings.get() if (!missingSettings.isEmpty()) { throw new GradleException( "Release build is blocked. Configure: ${missingSettings.join(', ')}" ) } if (!new File(releaseStoreFilePath.get()).isFile()) { throw new GradleException("D3RO_RELEASE_STORE_FILE must point to an existing keystore") } def adMobAppId = productionAdMobAppId.get() if (!(adMobAppId ==~ /ca-app-pub-\d+~\d+/)) { throw new GradleException("D3RO_ADMOB_APP_ID has an invalid format") } if (adMobAppId.startsWith(GOOGLE_TEST_ADMOB_PUBLISHER_ID + "~")) { throw new GradleException( "D3RO_ADMOB_APP_ID must not use Google's official test publisher" ) } [ D3RO_ADMOB_BANNER_UNIT_ID: productionBannerUnitId.get(), D3RO_ADMOB_REWARDED_UNIT_ID: productionRewardedUnitId.get(), ].each { name, value -> if (!(value ==~ /ca-app-pub-\d+\/\d+/)) { throw new GradleException("${name} has an invalid format") } if (value.startsWith(GOOGLE_TEST_ADMOB_PUBLISHER_ID + "/")) { throw new GradleException( "${name} must not use Google's official test publisher" ) } } } } def firebaseConfigFile = file("google-services.json") if (firebaseConfigFile.isFile()) { apply plugin: "com.google.gms.google-services" } def secureSetting = { String name -> def gradleValue = providers.gradleProperty(name) if (gradleValue.present) { return gradleValue.get() } def environmentValue = providers.environmentVariable(name) return environmentValue.present ? environmentValue.get() : null } def releaseStoreFilePath = secureSetting("D3RO_RELEASE_STORE_FILE") def releaseStorePassword = secureSetting("D3RO_RELEASE_STORE_PASSWORD") def releaseKeyAlias = secureSetting("D3RO_RELEASE_KEY_ALIAS") def releaseKeyPassword = secureSetting("D3RO_RELEASE_KEY_PASSWORD") def releaseSigningReady = [ releaseStoreFilePath, releaseStorePassword, releaseKeyAlias, releaseKeyPassword, ].every { it != null && !it.trim().isEmpty() } def productionAdMobAppId = secureSetting("D3RO_ADMOB_APP_ID") def productionBannerUnitId = secureSetting("D3RO_ADMOB_BANNER_UNIT_ID") def productionRewardedUnitId = secureSetting("D3RO_ADMOB_REWARDED_UNIT_ID") def configuredVersionName = secureSetting("D3RO_VERSION_NAME") def configuredVersionCode = secureSetting("D3RO_VERSION_CODE") def e2eSupabaseUrlOverride = secureSetting("D3RO_E2E_SUPABASE_URL") ?: "" def e2eSupabaseAnonKeyOverride = secureSetting("D3RO_E2E_SUPABASE_ANON_KEY") ?: "" def e2eSupabaseOverridePresent = !e2eSupabaseUrlOverride.isEmpty() || !e2eSupabaseAnonKeyOverride.isEmpty() def e2eSupabaseOverrideValid = e2eSupabaseUrlOverride == "http://10.0.2.2:55321" && e2eSupabaseAnonKeyOverride ==~ /sb_publishable_[A-Za-z0-9_-]{20,}/ if (e2eSupabaseOverridePresent && !e2eSupabaseOverrideValid) { throw new GradleException( "Local mobile E2E Supabase override must use the exact emulator endpoint and a publishable key" ) } def strictSemver = ~/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ def configuredVersionCodeValue = configuredVersionCode != null && configuredVersionCode ==~ /[1-9]\d{0,9}/ ? configuredVersionCode.toLong() : null def versionSettingsValid = configuredVersionName != null && configuredVersionName.length() <= 100 && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.2.0" def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1020001 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, D3RO_RELEASE_STORE_PASSWORD: releaseStorePassword, D3RO_RELEASE_KEY_ALIAS: releaseKeyAlias, D3RO_RELEASE_KEY_PASSWORD: releaseKeyPassword, D3RO_ADMOB_APP_ID: productionAdMobAppId, D3RO_ADMOB_BANNER_UNIT_ID: productionBannerUnitId, D3RO_ADMOB_REWARDED_UNIT_ID: productionRewardedUnitId, ] def missingReleaseSettingNames = requiredReleaseSettings.findAll { it.value == null || it.value.trim().isEmpty() }.keySet().toList().sort() def releaseStoreAbsolutePath = releaseStoreFilePath == null || releaseStoreFilePath.trim().isEmpty() ? null : file(releaseStoreFilePath).absolutePath def releaseAdMobAppIdValue = productionAdMobAppId def releaseBannerUnitIdValue = productionBannerUnitId def releaseRewardedUnitIdValue = productionRewardedUnitId def configureVersionGate = { VerifyD3roMobileVariantConfiguration gate -> if (configuredVersionName != null) { gate.versionNameSetting.set(configuredVersionName) } if (configuredVersionCode != null) { gate.versionCodeSetting.set(configuredVersionCode) } gate.missingReleaseSettings.set([]) } def verifyE2eBuildConfiguration = tasks.register( "verifyE2eBuildConfiguration", VerifyD3roMobileVariantConfiguration ) { group = "verification" description = "Fails closed unless the e2e version contract is configured." variantName.set("e2e") configureVersionGate(delegate) } def verifyReleaseBuildConfiguration = tasks.register( "verifyReleaseBuildConfiguration", VerifyD3roMobileVariantConfiguration ) { group = "verification" description = "Fails closed unless all production release settings are configured." variantName.set("release") configureVersionGate(delegate) missingReleaseSettings.set(missingReleaseSettingNames) firebaseConfigPath.set(firebaseConfigFile.absolutePath) if (releaseStoreAbsolutePath != null) { releaseStoreFilePath.set(releaseStoreAbsolutePath) } if (releaseAdMobAppIdValue != null) { productionAdMobAppId.set(releaseAdMobAppIdValue) } if (releaseBannerUnitIdValue != null) { productionBannerUnitId.set(releaseBannerUnitIdValue) } if (releaseRewardedUnitIdValue != null) { productionRewardedUnitId.set(releaseRewardedUnitIdValue) } } // Wire gates to actual Android variant lifecycle tasks. Aggregate invocations // such as `assemble` and `build` cannot bypass these dependencies merely because // their requested task names omit the build type. tasks.configureEach { candidate -> if (candidate.name == "preE2eBuild") { candidate.dependsOn(verifyE2eBuildConfiguration) } else if (candidate.name == "preReleaseBuild") { candidate.dependsOn(verifyReleaseBuildConfiguration) } } def debugAdMobAppId = "ca-app-pub-3940256099942544~3347511713" def debugBannerUnitId = "ca-app-pub-3940256099942544/6300978111" def debugRewardedUnitId = "ca-app-pub-3940256099942544/5224354917" def whisperModelFile = file("src/main/assets/models/ggml-tiny.bin") def whisperModelSize = 77691713L def whisperModelSha256 = "be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21" def verifyWhisperModel = tasks.register("verifyWhisperModel") { inputs.file(whisperModelFile) doLast { if (!whisperModelFile.isFile()) { throw new GradleException("Missing bundled Whisper model: ${whisperModelFile}") } if (whisperModelFile.length() != whisperModelSize) { throw new GradleException("Bundled Whisper model has an invalid size") } def digest = java.security.MessageDigest.getInstance("SHA-256") whisperModelFile.withInputStream { stream -> byte[] buffer = new byte[1024 * 1024] int read while ((read = stream.read(buffer)) > 0) { digest.update(buffer, 0, read) } } def actualSha256 = digest.digest().collect { String.format("%02x", it & 0xff) }.join() if (actualSha256 != whisperModelSha256) { throw new GradleException("Bundled Whisper model checksum verification failed") } } } tasks.named("preBuild").configure { dependsOn(verifyWhisperModel) } def asBuildConfigString = { String value -> return "\"${(value ?: '').replace('\\', '\\\\').replace('\"', '\\\"')}\"" } react { autolinkLibrariesWithApp() // Only the developer debug variant relies on Metro. The signed `e2e` // variant below must embed its Hermes bundle for clean-install emulator // testing and CI cold starts. debuggableVariants = ["debug"] } def enableProguardInReleaseBuilds = false def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' android { ndkVersion rootProject.ext.ndkVersion buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion namespace "com.d3ro.voice" buildFeatures { buildConfig true } androidResources { noCompress += ["bin"] } defaultConfig { applicationId "com.d3ro.voice" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" versionCode resolvedVersionCode versionName resolvedVersionName manifestPlaceholders = [ usesCleartextTraffic: "false", d3roAdMobAppId: debugAdMobAppId, ] buildConfigField "boolean", "FIREBASE_CONFIGURED", firebaseConfigFile.isFile().toString() buildConfigField "boolean", "E2E_TEST_BUILD", "false" buildConfigField "String", "SUPABASE_URL_OVERRIDE", asBuildConfigString("") buildConfigField "String", "SUPABASE_ANON_KEY_OVERRIDE", asBuildConfigString("") } signingConfigs { debug { storeFile file('debug.keystore') storePassword 'android' keyAlias 'androiddebugkey' keyPassword 'android' } if (releaseSigningReady) { release { storeFile file(releaseStoreFilePath) storePassword releaseStorePassword keyAlias releaseKeyAlias keyPassword releaseKeyPassword v1SigningEnabled true v2SigningEnabled true enableV3Signing = true enableV4Signing = true } } } buildTypes { debug { signingConfig signingConfigs.debug manifestPlaceholders = [ usesCleartextTraffic: "true", d3roAdMobAppId: debugAdMobAppId, ] buildConfigField "String", "ADMOB_BANNER_UNIT_ID", asBuildConfigString(debugBannerUnitId) buildConfigField "String", "ADMOB_REWARDED_UNIT_ID", asBuildConfigString(debugRewardedUnitId) ndk { abiFilters "arm64-v8a", "x86_64" } } e2e { initWith(buildTypes.debug) debuggable false signingConfig signingConfigs.debug // A non-debuggable E2E app is compiled as RelWithDebInfo. Keep // native dependency variants on the same NDEBUG/props ABI. matchingFallbacks = ["release"] manifestPlaceholders = [ usesCleartextTraffic: e2eSupabaseOverrideValid ? "true" : "false", d3roAdMobAppId: debugAdMobAppId, ] buildConfigField "boolean", "E2E_TEST_BUILD", "true" buildConfigField "String", "SUPABASE_URL_OVERRIDE", asBuildConfigString(e2eSupabaseUrlOverride) buildConfigField "String", "SUPABASE_ANON_KEY_OVERRIDE", asBuildConfigString(e2eSupabaseAnonKeyOverride) buildConfigField "String", "ADMOB_BANNER_UNIT_ID", asBuildConfigString(debugBannerUnitId) buildConfigField "String", "ADMOB_REWARDED_UNIT_ID", asBuildConfigString(debugRewardedUnitId) ndk { abiFilters.clear() abiFilters.add("arm64-v8a") abiFilters.add("x86_64") } } release { if (releaseSigningReady) { signingConfig signingConfigs.release } manifestPlaceholders = [ usesCleartextTraffic: "false", d3roAdMobAppId: productionAdMobAppId ?: debugAdMobAppId, ] buildConfigField "String", "ADMOB_BANNER_UNIT_ID", asBuildConfigString(productionBannerUnitId) buildConfigField "String", "ADMOB_REWARDED_UNIT_ID", asBuildConfigString(productionRewardedUnitId) ndk { abiFilters "arm64-v8a" } minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } } androidComponents { onVariants(selector().withBuildType("release")) { variant -> variant.packaging.jniLibs.excludes.add("**/x86_64/**") } } dependencies { implementation("com.facebook.react:react-android") implementation platform("com.google.firebase:firebase-bom:34.17.0") implementation("com.google.firebase:firebase-messaging") if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { implementation jscFlavor } androidTestImplementation("androidx.test:runner:1.7.0") androidTestImplementation("androidx.test.ext:junit:1.3.0") }