Skip to content

feat(google-services-plugin): inject Firebase App Check debug token in debug builds - #481

Merged
rlazo merged 3 commits into
google:mainfrom
AustinBenoit:feat/appcheck-seamless-debug-tokens
Nov 4, 2026
Merged

rlazo merged 3 commits into
google:mainfrom
AustinBenoit:feat/appcheck-seamless-debug-tokens

Conversation

@AustinBenoit

Copy link
Copy Markdown
Contributor

Summary

Implements the com.google.gms.google-services Gradle plugin build-time injection for Firebase App Check Seamless Debug Tokens (go/firebase-api-sdk-cli-seamless-debug-tokens):

  • During Debug variant builds (buildType == "debug" or variant name ending with "Debug"), GoogleServicesTask checks ~/.config/configstore/firebase-appcheck.json (and $XDG_CONFIG_HOME/configstore/firebase-appcheck.json, or -Pfirebase.appcheck.configFile override) for a debug token matching the variant's mobilesdk_app_id.
  • When present, injects <string name="firebase_app_check_debug_token" translatable="false">...</string> into the generated values/values.xml resource file consumed by firebase-appcheck-debug at runtime.
  • Release builds skip injection completely so debug tokens are never packaged into release APKs/AABs.
  • Uses Gradle ProviderFactory (project.providers) and @PathSensitive(PathSensitivity.RELATIVE) @InputFiles for full Gradle Configuration Cache and incremental build compatibility.

Testing

  • Added unit tests in GoogleServicesPluginTest:
    • appCheck debug token injected in debug build and omitted in release build
    • malformed appCheck config file is ignored gracefully

@rlazo rlazo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! Overall the approach looks solid (lazy providers, task inputs declared, release builds skipped). A few comments inline around cache behavior, debug detection, and config-file resolution.

it.applicationId.set(packageName)
it.gmpAppId.set(project.layout.buildDirectory.file("gmpAppId/${variant.name}.txt"))
it.debugBuild.set(isDebug)
it.appCheckConfigFiles.set(getAppCheckConfigFiles(project))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] appCheckConfigFiles is wired as a task input for every variant, including release.

Since the task is @CacheableTask, declaring ~/.config/configstore/firebase-appcheck.json as an @InputFiles for release variants means (a) any token refresh by the Firebase CLI invalidates up-to-date checks for release processXGoogleServices tasks, and (b) release build-cache keys now differ per developer (file content hash) even though the file has zero effect on release output, killing remote cache hits. Only wire the files when the variant is debug.

Suggested change
it.appCheckConfigFiles.set(getAppCheckConfigFiles(project))
if (isDebug) {
it.appCheckConfigFiles.set(getAppCheckConfigFiles(project))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — gated appCheckConfigFiles registration on if (isDebug) so release task inputs and cache keys are unaffected.

Comment on lines +90 to +92
val isDebug =
buildType.equals("debug", ignoreCase = true) ||
variant.name.endsWith("Debug", ignoreCase = true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Debug detection relies on naming heuristics rather than the build type's isDebuggable flag.

A custom build type such as staging { initWith(debug) } (debuggable, commonly used for App Check testing) is skipped, while a non-debuggable build type that merely ends in Debug is matched and gets a token packaged. For application modules the DSL is finalized by onVariants, so the real flag is available; the name heuristic could remain as a fallback for module types whose BuildType DSL lacks isDebuggable.

Suggested change
val isDebug =
buildType.equals("debug", ignoreCase = true) ||
variant.name.endsWith("Debug", ignoreCase = true)
val isDebug =
project.extensions
.findByType(ApplicationExtension::class.java)
?.buildTypes
?.findByName(buildType)
?.isDebuggable
?: (buildType.equals("debug", ignoreCase = true) ||
variant.name.endsWith("Debug", ignoreCase = true))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — now checks ApplicationExtension.buildTypes.findByName(buildType)?.isDebuggable first with the name heuristic as a fallback for non-application modules.

project.providers.gradleProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull
?: project.providers.systemProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull
if (!overridePath.isNullOrBlank()) {
return listOf(File(overridePath))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] A relative -Pfirebase.appcheck.configFile path is resolved against the daemon's working directory.

File(overridePath) with a relative path resolves against the JVM CWD, which for a Gradle daemon is not the project directory, so e.g. -Pfirebase.appcheck.configFile=.secrets/appcheck.json silently finds nothing.

Suggested change
return listOf(File(overridePath))
return listOf(project.file(overridePath))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — switched to project.file(overridePath).

Comment on lines +149 to +159
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)
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] ~/.config is searched before $XDG_CONFIG_HOME, inverting the Firebase CLI's precedence.

The CLI's configstore uses XDG_CONFIG_HOME instead of ~/.config when it is set. Because the loop in resolveAppCheckDebugToken returns on the first match, a stale ~/.config/configstore/firebase-appcheck.json will shadow the live file the CLI is actually writing to. Add the XDG location first.

Suggested change
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)
}
}
val xdgConfigHome = project.providers.environmentVariable("XDG_CONFIG_HOME").orNull
if (!xdgConfigHome.isNullOrBlank()) {
files.add(File(xdgConfigHome, "configstore/$APPCHECK_CONFIG_FILE_NAME"))
}
val userHome = project.providers.systemProperty("user.home").orNull
if (!userHome.isNullOrBlank()) {
val homeFile = File(userHome, ".config/configstore/$APPCHECK_CONFIG_FILE_NAME")
if (!files.contains(homeFile)) {
files.add(homeFile)
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think this is intended, isn't it @AustinBenoit ?

@AustinBenoit AustinBenoit Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification on this one (updated in def72a0 with an inline comment): rather than using default configstore path resolution (where XDG_CONFIG_HOME replaces ~/.config), the CLI implementation for this feature (go/firebase-api-sdk-cli-seamless-debug-tokens) explicitly passes $HOME/.config/configstore/firebase-appcheck.json as the canonical primary configPath and mirrors writes to $XDG_CONFIG_HOME/configstore/firebase-appcheck.json when XDG_CONFIG_HOME is set. That way, if a developer sets XDG_CONFIG_HOME only in their interactive shell (e.g. .zshrc) and not in their GUI Android Studio / Gradle daemon environment (or later unsets XDG_CONFIG_HOME), ~/.config/configstore/firebase-appcheck.json is always guaranteed to be the up-to-date primary file.

continue
}
try {
val root = JsonParser().parse(configFile.bufferedReader(Charsets.UTF_8))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] The BufferedReader opened on the config file is never closed.

JsonParser.parse(Reader) does not close the reader, so the file handle leaks until GC (and on Windows can keep the configstore file locked while the CLI tries to rewrite it).

Suggested change
val root = JsonParser().parse(configFile.bufferedReader(Charsets.UTF_8))
val root = configFile.bufferedReader(Charsets.UTF_8).use { JsonParser().parse(it) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — wrapped bufferedReader in .use { ... }.


@get:OutputFile abstract val gmpAppId: RegularFileProperty

@get:Optional @get:Input abstract val debugBuild: Property<Boolean>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Debug tokens are written into the outputs of a @CacheableTask, so they get pushed to any configured remote build cache.

The generated values.xml containing the per-developer secret becomes a build-cache artifact. The cache key includes the config file hash so cross-developer hits are unlikely, but the token is still persisted in shared infrastructure the CLI explicitly tells users not to commit. Consider disabling caching of this task when a token is actually injected, e.g. in the plugin's task registration:

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 }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — added outputs.doNotCacheIf when debugBuild is true and a local config file is present.

val tokenPrimitive = tokensObj.getAsJsonPrimitive(googleAppId) ?: continue
if (tokenPrimitive.isString) {
val token = tokenPrimitive.asString.trim()
if (token.isNotEmpty() && !token.any { it == '<' || it == '>' || it == '&' || it == '"' }) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] XML sanitization misses characters that AAPT treats specially in string resources.

<, >, &, " are rejected, but a token starting with @ or ? is interpreted as a resource reference, and an unescaped ' fails aapt2 compilation. Either escape the value properly or extend the reject list, e.g. token.none { it in "<>&\"'" } && !token.startsWith("@") && !token.startsWith("?").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 653b280 — extended the reject check to exclude ' and leading @ / ?, and added unit test coverage in GoogleServicesPluginTest.

@AustinBenoit
AustinBenoit marked this pull request as ready for review November 3, 2026 21:47
@rlazo
rlazo merged commit 8cd8140 into google:main Nov 4, 2026
13 checks passed
@AustinBenoit
AustinBenoit deleted the feat/appcheck-seamless-debug-tokens branch November 4, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants