diff --git a/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesPlugin.kt b/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesPlugin.kt index 73500ef8..5a408d65 100644 --- a/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesPlugin.kt +++ b/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesPlugin.kt @@ -15,6 +15,7 @@ */ package com.google.gms.googleservices +import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.DynamicFeatureAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension @@ -86,6 +87,15 @@ class GoogleServicesPlugin : Plugin { } private fun handleVariant(variant: Variant, packageName: Provider, project: Project) { + val buildType = variant.buildType.orEmpty() + val isDebug = + project.extensions + .findByType(ApplicationExtension::class.java) + ?.buildTypes + ?.findByName(buildType) + ?.isDebuggable + ?: (buildType.equals("debug", ignoreCase = true) || + variant.name.endsWith("Debug", ignoreCase = true)) val jsonToXmlTask = project.tasks.register( "process${variant.name.capitalize()}GoogleServices", GoogleServicesTask::class.java) { @@ -95,11 +105,19 @@ class GoogleServicesPlugin : Plugin { .missingGoogleServicesStrategy) it.googleServicesJsonFiles.set( getJsonFiles( - variant.buildType.orEmpty(), + buildType, variant.productFlavors.map { it.second }, project.projectDir)) it.applicationId.set(packageName) it.gmpAppId.set(project.layout.buildDirectory.file("gmpAppId/${variant.name}.txt")) + it.debugBuild.set(isDebug) + if (isDebug) { + it.appCheckConfigFiles.set(getAppCheckConfigFiles(project)) + } + it.outputs.doNotCacheIf("Firebase App Check debug token is injected") { task -> + (task as GoogleServicesTask).debugBuild.getOrElse(false) && + task.appCheckConfigFiles.getOrElse(emptyList()).any { f -> f.isFile } + } } // TODO: add an AGP version check to this block @@ -129,6 +147,34 @@ class GoogleServicesPlugin : Plugin { const val MINIMUM_VERSION = "9.0.0" const val SOURCE_TYPE = "google-services" const val JSON_FILE_NAME = "google-services.json" + const val APPCHECK_CONFIG_FILE_PROPERTY = "firebase.appcheck.configFile" + const val APPCHECK_CONFIG_FILE_NAME = "firebase-appcheck.json" + + fun getAppCheckConfigFiles(project: Project): List { + val overridePath = + project.providers.gradleProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull + ?: project.providers.systemProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull + if (!overridePath.isNullOrBlank()) { + return listOf(project.file(overridePath)) + } + val files = mutableListOf() + // Per go/firebase-api-sdk-cli-seamless-debug-tokens, the Firebase CLI always writes + // $HOME/.config/configstore/firebase-appcheck.json as the canonical primary path and + // mirrors to $XDG_CONFIG_HOME/configstore/firebase-appcheck.json when XDG_CONFIG_HOME + // is set, so GUI IDEs/Gradle daemons and interactive shells never diverge. + val userHome = project.providers.systemProperty("user.home").orNull + if (!userHome.isNullOrBlank()) { + files.add(File(userHome, ".config/configstore/$APPCHECK_CONFIG_FILE_NAME")) + } + val xdgConfigHome = project.providers.environmentVariable("XDG_CONFIG_HOME").orNull + if (!xdgConfigHome.isNullOrBlank()) { + val xdgFile = File(xdgConfigHome, "configstore/$APPCHECK_CONFIG_FILE_NAME") + if (!files.contains(xdgFile)) { + files.add(xdgFile) + } + } + return files + } fun getJsonFiles(buildType: String, flavorNames: List, root: File): List { return getJsonLocations(buildType, flavorNames).map { root.resolve(it) } diff --git a/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesTask.kt b/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesTask.kt index 5235699e..04284cac 100644 --- a/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesTask.kt +++ b/google-services-plugin/src/main/kotlin/com/google/gms/googleservices/GoogleServicesTask.kt @@ -30,6 +30,7 @@ import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive @@ -57,6 +58,13 @@ abstract class GoogleServicesTask : DefaultTask() { @get:OutputFile abstract val gmpAppId: RegularFileProperty + @get:Optional @get:Input abstract val debugBuild: Property + + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFiles + abstract val appCheckConfigFiles: Property> + @Throws(GradleException::class) @TaskAction fun action() { @@ -190,6 +198,7 @@ abstract class GoogleServicesTask : DefaultTask() { companion object { const val JSON_FILE_NAME = "google-services.json" + const val APPCHECK_DEBUG_TOKEN_RES_NAME = "firebase_app_check_debug_token" private const val STATUS_DISABLED = "1" private const val STATUS_ENABLED = "2" private const val OAUTH_CLIENT_TYPE_WEB = "3" @@ -235,6 +244,45 @@ abstract class GoogleServicesTask : DefaultTask() { } resValues["google_app_id"] = googleAppIdStr gmpAppId.get().asFile.writeText(googleAppIdStr, Charsets.UTF_8) + if (debugBuild.getOrElse(false)) { + val debugToken = resolveAppCheckDebugToken(googleAppIdStr) + if (!debugToken.isNullOrEmpty()) { + resValues[APPCHECK_DEBUG_TOKEN_RES_NAME] = debugToken + } + } + } + + private fun resolveAppCheckDebugToken(googleAppId: String): String? { + val configFiles = appCheckConfigFiles.orNull ?: return null + for (configFile in configFiles) { + if (!configFile.isFile) { + continue + } + try { + val root = configFile.bufferedReader(Charsets.UTF_8).use { JsonParser().parse(it) } + if (!root.isJsonObject) { + continue + } + val tokensObj = root.asJsonObject.getAsJsonObject("tokens") ?: continue + val tokenPrimitive = tokensObj.getAsJsonPrimitive(googleAppId) ?: continue + if (tokenPrimitive.isString) { + val token = tokenPrimitive.asString.trim() + if ( + token.isNotEmpty() && + token.none { it in "<>&\"'" } && + !token.startsWith("@") && + !token.startsWith("?") + ) { + logger.info( + "Injecting Firebase App Check debug token for app $googleAppId from ${configFile.path}") + return token + } + } + } catch (e: Exception) { + logger.warn("Failed to parse App Check config file at ${configFile.path}: ${e.message}") + } + } + return null } fun FirebaseClientData.handleWebClientId(resValues: MutableMap) { diff --git a/google-services-plugin/src/test/kotlin/com/google/gms/googleservices/GoogleServicesPluginTest.kt b/google-services-plugin/src/test/kotlin/com/google/gms/googleservices/GoogleServicesPluginTest.kt index 22130908..e5717f33 100644 --- a/google-services-plugin/src/test/kotlin/com/google/gms/googleservices/GoogleServicesPluginTest.kt +++ b/google-services-plugin/src/test/kotlin/com/google/gms/googleservices/GoogleServicesPluginTest.kt @@ -17,6 +17,7 @@ package com.google.gms.googleservices import java.io.File +import org.gradle.testfixtures.ProjectBuilder import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.Assert @@ -270,6 +271,123 @@ class GoogleServicesPluginTest { "src/flavor/testTestRelease/google-services.json"))) } + @Test + fun `appCheck debug token injected in debug build and omitted in release build`() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val googleServicesJson = File("src/test/testData/project1/app/google-services.json") + val appCheckConfigFile = tempFolder.newFile("firebase-appcheck.json") + val expectedToken = "9539cd91-5aa4-4505-b35c-0ee48ea23b04" + appCheckConfigFile.writeText( + """ + { + "_comment": "Warning: Do not commit this file to source control.", + "tokens": { + "1:123456789000:android:f1bf012572b04063": "$expectedToken" + } + } + """.trimIndent()) + + val debugOutputDir = tempFolder.newFolder("debugRes") + val debugGmpAppId = File(tempFolder.root, "debugGmpAppId.txt") + val debugTask = + project.tasks.register("processDebugGoogleServicesTest", GoogleServicesTask::class.java) { + it.googleServicesJsonFiles.set(listOf(googleServicesJson)) + it.applicationId.set("com.google.samples.quickstart.admobexample") + it.missingGoogleServicesStrategy.set( + GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR) + it.outputDirectory.set(debugOutputDir) + it.gmpAppId.set(debugGmpAppId) + it.debugBuild.set(true) + it.appCheckConfigFiles.set(listOf(appCheckConfigFile)) + } + debugTask.get().action() + + val debugValuesXml = File(debugOutputDir, "values/values.xml").readText() + Assert.assertTrue( + debugValuesXml.contains( + """$expectedToken""")) + + val releaseOutputDir = tempFolder.newFolder("releaseRes") + val releaseGmpAppId = File(tempFolder.root, "releaseGmpAppId.txt") + val releaseTask = + project.tasks.register( + "processReleaseGoogleServicesTest", GoogleServicesTask::class.java) { + it.googleServicesJsonFiles.set(listOf(googleServicesJson)) + it.applicationId.set("com.google.samples.quickstart.admobexample") + it.missingGoogleServicesStrategy.set( + GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR) + it.outputDirectory.set(releaseOutputDir) + it.gmpAppId.set(releaseGmpAppId) + it.debugBuild.set(false) + it.appCheckConfigFiles.set(listOf(appCheckConfigFile)) + } + releaseTask.get().action() + + val releaseValuesXml = File(releaseOutputDir, "values/values.xml").readText() + Assert.assertFalse(releaseValuesXml.contains("firebase_app_check_debug_token")) + } + + @Test + fun `malformed appCheck config file is ignored gracefully`() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val googleServicesJson = File("src/test/testData/project1/app/google-services.json") + val malformedConfigFile = tempFolder.newFile("malformed-firebase-appcheck.json") + malformedConfigFile.writeText("{ invalid json") + + val outputDir = tempFolder.newFolder("malformedRes") + val gmpAppId = File(tempFolder.root, "malformedGmpAppId.txt") + val task = + project.tasks.register( + "processMalformedGoogleServicesTest", GoogleServicesTask::class.java) { + it.googleServicesJsonFiles.set(listOf(googleServicesJson)) + it.applicationId.set("com.google.samples.quickstart.admobexample") + it.missingGoogleServicesStrategy.set( + GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR) + it.outputDirectory.set(outputDir) + it.gmpAppId.set(gmpAppId) + it.debugBuild.set(true) + it.appCheckConfigFiles.set(listOf(malformedConfigFile)) + } + task.get().action() + + val valuesXml = File(outputDir, "values/values.xml").readText() + Assert.assertTrue(valuesXml.contains("""") + + for ((idx, invalidToken) in invalidTokens.withIndex()) { + val configFile = tempFolder.newFile("invalid-appcheck-$idx.json") + configFile.writeText( + """{"tokens":{"1:123456789000:android:f1bf012572b04063":"$invalidToken"}}""") + val outputDir = tempFolder.newFolder("invalidRes-$idx") + val gmpAppId = File(tempFolder.root, "invalidGmpAppId-$idx.txt") + val task = + project.tasks.register( + "processInvalidToken${idx}GoogleServicesTest", GoogleServicesTask::class.java) { + it.googleServicesJsonFiles.set(listOf(googleServicesJson)) + it.applicationId.set("com.google.samples.quickstart.admobexample") + it.missingGoogleServicesStrategy.set( + GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR) + it.outputDirectory.set(outputDir) + it.gmpAppId.set(gmpAppId) + it.debugBuild.set(true) + it.appCheckConfigFiles.set(listOf(configFile)) + } + task.get().action() + + val valuesXml = File(outputDir, "values/values.xml").readText() + Assert.assertFalse( + "Expected token '$invalidToken' to be rejected", + valuesXml.contains("firebase_app_check_debug_token")) + } + } + // /* Uncomment, edit project paths and run this test to regenerate the // XML files that will be used for comparing output. */ // @Test