Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions firebase-crashlytics-gradle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
### Unreleased

- [fixed] Fixed an incompatibility between Crashlytics Gradle plugin and Gradle isolated projects when enabling nativeSymbolUploadEnabled. [#8037]
- [fixed] Stopped regenerating the mapping file id on every minified release build. `injectCrashlyticsMappingFileId<Variant>` is now content-driven: the task stays `UP-TO-DATE` (and downstream `mergeResources`, `processResources`, R8, packaging tasks stay cached) while the variant's source files (`src/**/java`, `src/**/kotlin`, excluding tests) are unchanged. A new id is minted when those sources change — which is when R8 would produce a different `mapping.txt` — and on first build, after `clean`, or when `mappingFileUploadEnabled` is toggled. [#6770]

### Crashlytics Gradle plugin version 3.0.7

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPluginTest.C
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPluginTest.Companion.pluginVersion
import java.io.File
import org.gradle.testkit.runner.TaskOutcome.SUCCESS
import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE
import org.gradle.testkit.runner.UnexpectedBuildFailure
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -217,6 +218,143 @@ class TypicalAppFunctionalTests {
)
}

@Test
fun `injectCrashlyticsMappingFileIdRelease is UP_TO_DATE on second invocation`() {
val first =
buildGradleRunner(
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.

@OOS93 Applied your suggestion

projectDir,
":injectCrashlyticsMappingFileIdRelease",
"--configuration-cache"
)

assertThat(first.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)

val second =
buildGradleRunner(
projectDir,
":injectCrashlyticsMappingFileIdRelease",
"--configuration-cache"
)

assertThat(second.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(UP_TO_DATE)
}

@Test
fun `release mapping file id is preserved across rebuilds`() {
buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease", "--configuration-cache")

val idFile = File(projectDir, "build/crashlytics/release/mappingFileId.txt")
val idBefore = idFile.readText()
assertThat(idBefore).isNotEmpty()

buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease", "--configuration-cache")

assertThat(idFile.readText()).isEqualTo(idBefore)
}

@Test
fun `task re-runs after clean`() {
buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease", "--configuration-cache")

val idFile = File(projectDir, "build/crashlytics/release/mappingFileId.txt")

val rerun =
buildGradleRunner(
projectDir,
":clean",
":injectCrashlyticsMappingFileIdRelease",
"--configuration-cache",
)

assertThat(rerun.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)
assertThat(idFile.exists()).isTrue()
assertThat(idFile.readText()).isNotEmpty()
}

@Test
fun `toggling mappingFileUploadEnabled invalidates the task`() {
val first =
buildGradleRunner(
projectDir,
":injectCrashlyticsMappingFileIdRelease",
"--configuration-cache"
)

assertThat(first.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)
val idFile = File(projectDir, "build/crashlytics/release/mappingFileId.txt")
assertThat(idFile.readText()).isEqualTo("test321")

buildFile.writeText(
"""
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension

plugins {
id("com.android.application") version "8.1.4"
id("com.google.gms.google-services") version "4.4.1"
id("com.google.firebase.crashlytics") version "$pluginVersion"
}

android {
compileSdk = 33
namespace = "com.google.firebase.testing.crashlytics"

buildTypes {
release {
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = false
}
isMinifyEnabled = true
}
}
}
"""
)

val second =
buildGradleRunner(
projectDir,
":injectCrashlyticsMappingFileIdRelease",
"--configuration-cache"
)

assertThat(second.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)
assertThat(idFile.readText()).isEqualTo("00000000000000000000000000000000")
}

@Test
fun `release mapping file id task is invalidated when source code changes`() {
val sourceFile =
File(projectDir, "src/main/kotlin/com/google/firebase/testing/crashlytics/Greeter.kt")
sourceFile.parentFile.mkdirs()
sourceFile.writeText(
"""
package com.google.firebase.testing.crashlytics
class Greeter { fun hello() = "hello" }
"""
)

val first = buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease")
assertThat(first.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)

// Re-running without changing inputs must NOT regenerate the id (would invalidate downstream
// R8, packaging, etc. — the bug PR #8185 originally fixed).
val noChange = buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease")
assertThat(noChange.task(":injectCrashlyticsMappingFileIdRelease")?.outcome)
.isEqualTo(UP_TO_DATE)

// Editing the source MUST invalidate the task so a new id is minted: the on-device id is the
// handle Crashlytics uses to match crashes to the right uploaded mapping.txt, and R8 will
// produce a different mapping.txt after this edit.
sourceFile.writeText(
"""
package com.google.firebase.testing.crashlytics
class Greeter { fun hello() = "hello world" }
"""
)
val afterEdit = buildGradleRunner(projectDir, ":injectCrashlyticsMappingFileIdRelease")
assertThat(afterEdit.task(":injectCrashlyticsMappingFileIdRelease")?.outcome).isEqualTo(SUCCESS)
}

@BeforeEach
fun setup() {
settingsFile = File(projectDir, "settings.gradle.kts")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,32 @@ import com.google.firebase.crashlytics.buildtools.mappingfiles.MappingFileIdWrit
import java.io.File
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.TaskProvider
import org.gradle.kotlin.dsl.register

/** Inject mapping file id task. */
@CacheableTask
abstract class InjectMappingFileIdTask : DefaultTask() {
@get:Internal abstract val useBlankMappingFileId: Property<Boolean>
@get:Input abstract val useBlankMappingFileId: Property<Boolean>

@get:[InputFiles PathSensitive(PathSensitivity.RELATIVE)]
abstract val obfuscatableSources: ConfigurableFileCollection

@get:[InputFiles PathSensitive(PathSensitivity.NONE)]
abstract val obfuscatableClasspath: ConfigurableFileCollection

@get:OutputFile abstract val mappingFileIdFile: RegularFileProperty
@get:OutputDirectory abstract val resourceDir: DirectoryProperty

Expand All @@ -66,32 +77,45 @@ abstract class InjectMappingFileIdTask : DefaultTask() {
)
}

/**
* Check if a mapping file id file already exists, and that the mapping file id is blank - meaning
* no obfuscation is enabled. The Crashlytics SDK always needs a mapping file id.
*/
private fun blankMappingFileIdExists(): Boolean {
val file: File = mappingFileIdFile.get().asFile
return file.exists() && file.readText() == CrashlyticsBuildtools.BLANK_MAPPING_FILE_ID
}

internal companion object {
@Suppress("UnstableApiUsage") // isMinifyEnabled
@Suppress("UnstableApiUsage") // isMinifyEnabled, compileClasspath
fun register(
project: Project,
variant: ApplicationVariant,
crashlyticsExtension: CrashlyticsVariantExtension,
): TaskProvider<InjectMappingFileIdTask> {
val useBlank =
!crashlyticsExtension.mappingFileUploadEnabled.getOrElse(variant.isMinifyEnabled)

val injectMappingFileIdTaskProvider =
project.tasks.register<InjectMappingFileIdTask>(
"injectCrashlyticsMappingFileId${variant.name.capitalized()}"
) {
this.useBlankMappingFileId.set(
!crashlyticsExtension.mappingFileUploadEnabled.getOrElse(variant.isMinifyEnabled)
)
this.useBlankMappingFileId.set(useBlank)
this.mappingFileIdFile.set(buildFile(project, variant, "mappingFileId.txt"))

outputs.upToDateWhen { useBlankMappingFileId.get() && blankMappingFileIdExists() }
// Only fingerprint inputs when obfuscation is on. In blank-id mode the id is constant
// and source/classpath changes are irrelevant to the mapping handle.
//
// Discover user source files via project.fileTree("src") rather than
// variant.sources.java/kotlin.all. AGP's accessor includes generated source dirs
// (R.java, deeplinks, view-binding, etc.) whose producer tasks depend on the same
// mergeResources pipeline that consumes THIS task's output, which would close a cycle.
// AGP 8.1.4 has no `static` getter (added in 8.6) that would expose the non-generated
// subset, so we hand-roll the discovery from on-disk source-set conventions.
if (!useBlank) {
this.obfuscatableSources.from(
project.fileTree("src").matching { patterns ->
patterns.include(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We tried something like this before, it looked more like this.sourceFiles.from(variant.sources.java?.all, variant.sources.kotlin?.all)

But this created circular dependencies when apps used things like compose. If source depends on resources, and resources depend on sources, we have a circular dependency. Can you confirm if this has the same problem?

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.

Ran both implementations against the real repro6770 app

App configuration variant.sources.java?.all + kotlin?.all Current fileTree("src")
Compose only builds, no cycle builds, no cycle
Compose + viewBinding Circular dependency — BUILD FAILED builds; inject SUCCESS → UP-TO-DATE; id stable

Compose alone is not a trigger, the trigger is view/data binding. So the circular dependency will only be present if I use variant.sources.java?.all + kotlin?.all + view/data binding.

The project.fileTree("src") form builds cleanly on the same app (task UP-TO-DATE on rebuild, stable id) because it's a plain FileTree: no builtBy/task-dependency edges for Gradle to order against, and it only scans src/**. Never build/generated/** where the binding-generated sources live.

"**/java/**/*.java",
"**/java/**/*.kt",
"**/kotlin/**/*.java",
"**/kotlin/**/*.kt",
)
patterns.exclude("test/**", "androidTest/**", "test*/**", "androidTest*/**")
}
)
}
}

// It is not possible to disable Android resources in the AGP app plugin.
Expand Down
Loading