From 9d3072e179d52c9fed4364f88ee2238f2cfecc57 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 09:36:27 +0200 Subject: [PATCH 1/4] fix(android): Generate sentry.options.json into build folder instead of source tree The Android Gradle plugin copied `sentry.options.json` into the version-controlled `src/main/assets` during builds via a task with no declared inputs/outputs. That broke Gradle's up-to-date checks and build caching for the asset-merge tasks and required a cleanup task to remove the file afterward, which could leave the file behind on a failed build. Replace it with a typed `generateSentryOptions` task that writes into `build/generated/sentry/options` with declared inputs/outputs (source file plus the SENTRY_ENVIRONMENT/RELEASE/DIST overrides), registered as a generated assets source via the AGP Variant API, with a classic sourceSets fallback for older AGP. Nothing is written into the source tree anymore, and asset merging is now correctly cached. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + packages/core/sentry.gradle.kts | 276 ++++++++++++++++++++++---------- 2 files changed, 196 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda5b81ca1..4d0e8ccbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Declare optional peer dependencies so imports resolve under strict and Plug'n'Play package managers ([#6729](https://github.com/getsentry/sentry-react-native/pull/6729)) - Honor `shutdownTimeout` on iOS ([#6749](https://github.com/getsentry/sentry-react-native/pull/6749)) +- Android Gradle plugin no longer writes generated `sentry.options.json` into your source tree during builds ([#6750](https://github.com/getsentry/sentry-react-native/issues/6750)) ### Internal diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 98d33cea14..03f9d1e22f 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -1,6 +1,18 @@ import org.apache.tools.ant.taskdefs.condition.Os import org.codehaus.groovy.runtime.DefaultGroovyMethods +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.Exec +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.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction import java.io.FileInputStream import java.util.Properties import java.util.concurrent.atomic.AtomicBoolean @@ -78,9 +90,87 @@ interface InjectedExecOps { val execOps: org.gradle.process.ExecOperations } -interface InjectedFsOps { - @get:Inject - val fs: org.gradle.api.file.FileSystemOperations +/** + * Generates `sentry.options.json` into a `build` folder directory registered as a generated assets + * source, so nothing is written into the version-controlled `src/main/assets` tree. Declared + * inputs/outputs make it participate in up-to-date checks and the build cache; the action reads only + * captured inputs and does plain file I/O, so it is Configuration Cache compatible. + */ +abstract class GenerateSentryOptionsTask : DefaultTask() { + // File collection so a missing source is an empty input, not a failure. RELATIVE: only content matters. + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceOptionsFiles: ConfigurableFileCollection + + @get:Input + @get:Optional + abstract val environmentOverride: Property + + @get:Input + @get:Optional + abstract val releaseOverride: Property + + @get:Input + @get:Optional + abstract val distOverride: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + // Drives `onlyIf` (the `SENTRY_COPY_OPTIONS_FILE` opt-out). Task-owned so the spec references only + // the task, not the script object (Configuration Cache safe). `@Internal`: gates execution, not content. + @get:Internal + abstract val copyEnabled: Property + + @TaskAction + fun generate() { + val outDir = outputDir.get().asFile + outDir.mkdirs() + val dest = File(outDir, "sentry.options.json") + // Idempotent: clear any prior output so a removed source file leaves an empty dir. + if (dest.exists()) { + dest.delete() + } + + val source = sourceOptionsFiles.files.firstOrNull { it.exists() } + if (source == null) { + logger.warn("sentry.options.json not found in app root; generated assets directory left empty") + return + } + + val environment = environmentOverride.orNull + val release = releaseOverride.orNull + val dist = distOverride.orNull + + if (environment == null && release == null && dist == null) { + dest.writeText(source.readText()) + logger.lifecycle("Generated sentry.options.json into ${dest.parentFile}") + return + } + + try { + @Suppress("UNCHECKED_CAST") + val content = + groovy.json.JsonSlurper().parseText(source.readText()) as MutableMap + if (environment != null) { + content["environment"] = environment + logger.lifecycle("Overriding 'environment' from SENTRY_ENVIRONMENT environment variable") + } + if (release != null) { + content["release"] = release + logger.lifecycle("Overriding 'release' from SENTRY_RELEASE environment variable") + } + if (dist != null) { + content["dist"] = dist + logger.lifecycle("Overriding 'dist' from SENTRY_DIST environment variable") + } + dest.writeText(groovy.json.JsonOutput.toJson(content)) + } catch (e: Exception) { + logger.warn("Failed to override options in sentry.options.json: ${e.message}. Copied file as-is.") + dest.writeText(source.readText()) + } + logger.lifecycle("Generated sentry.options.json into ${dest.parentFile}") + } } extra["shouldCopySentryOptionsFile"] = @@ -103,81 +193,114 @@ val config: Map = } val configFile = "sentry.options.json" -val androidAssetsDir = File("$rootDir/app/src/main/assets") - -// Values captured at configuration time so task onlyIf specs and actions do not read -// `project` state at execution time (required for Gradle Configuration Cache compatibility). -// `copyOptionsFileEnabled` is a Property populated in `afterEvaluate` (below) rather than at -// apply-time, so a `project.ext.shouldCopySentryOptionsFile` override placed after `apply from` -// is still honored. Referencing the Property in `onlyIf` keeps the tasks Config Cache compatible. -// The convention preserves the documented default (copy enabled) if `afterEvaluate` never runs. -val copyOptionsFileEnabled = objects.property(Boolean::class.java).convention(true) + +// Captured at configuration time so task actions do not read `project` state at execution time +// (required for Gradle Configuration Cache compatibility). val rootDirFile = project.rootDir -tasks.register("copySentryJsonConfiguration") { - onlyIf { copyOptionsFileEnabled.get() } - val injectedFs = project.objects.newInstance(InjectedFsOps::class.java) - doLast { +// Build-folder dir holding the generated `sentry.options.json`, registered as a generated assets +// source (below) so AGP merges it into the packaged assets with correct task ordering and caching. +val sentryOptionsGeneratedDir = layout.buildDirectory.dir("generated/sentry/options") + +// Read at configuration time and passed as task inputs so up-to-date checks re-run on change. +val sentryOptionsEnvironment: String? = System.getenv("SENTRY_ENVIRONMENT") +val sentryOptionsRelease: String? = System.getenv("SENTRY_RELEASE") +val sentryOptionsDist: String? = System.getenv("SENTRY_DIST") + +val generateSentryOptionsTask = + tasks.register("generateSentryOptions", GenerateSentryOptionsTask::class.java) { + // onlyIf references only the task (Configuration Cache safe); opt-out resolved in afterEvaluate. + copyEnabled.convention(true) + onlyIf { (it as GenerateSentryOptionsTask).copyEnabled.get() } val appRoot = rootDirFile.parentFile ?: rootDirFile - val sentryOptionsFile = File(appRoot, configFile) - if (sentryOptionsFile.exists()) { - if (!androidAssetsDir.exists()) { - androidAssetsDir.mkdirs() - } + sourceOptionsFiles.from(File(appRoot, configFile)) + sentryOptionsEnvironment?.let { environmentOverride.set(it) } + sentryOptionsRelease?.let { releaseOverride.set(it) } + sentryOptionsDist?.let { distOverride.set(it) } + outputDir.set(sentryOptionsGeneratedDir) + } - injectedFs.fs.copy { - from(sentryOptionsFile) - into(androidAssetsDir) - rename { configFile } - } +// Guards the classic source-set fallback so it registers at most once, only when the variant API is absent. +val sentryOptionsSourceSetFallbackApplied = AtomicBoolean(false) - val sentryEnv = System.getenv("SENTRY_ENVIRONMENT") - val sentryRelease = System.getenv("SENTRY_RELEASE") - val sentryDist = System.getenv("SENTRY_DIST") - if (sentryEnv != null || sentryRelease != null || sentryDist != null) { - try { - val destFile = File(androidAssetsDir, configFile) - - @Suppress("UNCHECKED_CAST") - val content = groovy.json.JsonSlurper().parseText(destFile.readText()) as MutableMap - if (sentryEnv != null) { - content["environment"] = sentryEnv - } - if (sentryRelease != null) { - content["release"] = sentryRelease - } - if (sentryDist != null) { - content["dist"] = sentryDist - } - destFile.writeText(groovy.json.JsonOutput.toJson(content)) - if (sentryEnv != null) { - logger.lifecycle("Overriding 'environment' from SENTRY_ENVIRONMENT environment variable") - } - if (sentryRelease != null) { - logger.lifecycle("Overriding 'release' from SENTRY_RELEASE environment variable") - } - if (sentryDist != null) { - logger.lifecycle("Overriding 'dist' from SENTRY_DIST environment variable") - } - } catch (e: Exception) { - logger.warn("Failed to override options in $configFile: ${e.message}. Copied file as-is.") - } +fun applySentryOptionsSourceSetFallback() { + if (!sentryOptionsSourceSetFallbackApplied.compareAndSet(false, true)) return + try { + val android = extensions.getByName("android") + val sourceSets = android.javaClass.getMethod("getSourceSets").invoke(android) + val getByName = + sourceSets.javaClass.methods.first { it.name == "getByName" && it.parameterCount == 1 } + val mainSourceSet = getByName.invoke(sourceSets, "main") + val assets = mainSourceSet.javaClass.getMethod("getAssets").invoke(mainSourceSet) + val srcDir = + assets.javaClass.methods.first { + it.name == "srcDir" && it.parameterCount == 1 && it.parameterTypes[0] == Any::class.java } - logger.lifecycle("Copied $configFile to Android assets") - } else { - logger.warn("$configFile not found in app root ($appRoot)") - } + srcDir.invoke(assets, sentryOptionsGeneratedDir.get().asFile) + tasks + .matching { it.name.startsWith("merge") && it.name.endsWith("Assets") } + .configureEach { dependsOn(generateSentryOptionsTask) } + project.logger.info("[sentry] Wired sentry.options.json into assets via sourceSets fallback") + } catch (e: Exception) { + project.logger.warn( + "[sentry] Failed to wire sentry.options.json into assets: ${e.message}. " + + "sentry.options.json may not be packaged. Please report this issue at " + + "https://github.com/getsentry/sentry-react-native/issues", + ) } } -tasks.register("cleanupTemporarySentryJsonConfiguration") { - onlyIf { copyOptionsFileEnabled.get() } - doLast { - val sentryOptionsFile = File(androidAssetsDir, configFile) - if (sentryOptionsFile.exists()) { - logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") - sentryOptionsFile.delete() +// Wires the generated dir into a variant's assets via `addGeneratedSourceDirectory` (AGP 7.3+), +// reflectively since a script plugin can't depend on AGP types. Falls back to the source set otherwise. +fun wireSentryOptionsAssets(variant: Any) { + try { + val sources = variant.javaClass.getMethod("getSources").invoke(variant) + val assets = sources.javaClass.getMethod("getAssets").invoke(sources) + val addMethod = + assets?.javaClass?.methods?.firstOrNull { it.name == "addGeneratedSourceDirectory" } + if (assets == null || addMethod == null) { + applySentryOptionsSourceSetFallback() + return } + val wiredWith: (GenerateSentryOptionsTask) -> DirectoryProperty = { it.outputDir } + addMethod.invoke(assets, generateSentryOptionsTask, wiredWith) + } catch (e: Exception) { + project.logger.info("[sentry] variant assets wiring failed: ${e.message}. Falling back to sourceSets.") + applySentryOptionsSourceSetFallback() + } +} + +plugins.withId("com.android.application") { + try { + val androidComponents = extensions.getByName("androidComponents") + val selector = androidComponents.javaClass.getMethod("selector").invoke(androidComponents) + val allSelector = selector.javaClass.getMethod("all").invoke(selector) + val onVariantsMethod = + androidComponents.javaClass.methods.find { + it.name == "onVariants" && it.parameterCount == 2 && it.parameterTypes[1].isInterface + } ?: throw NoSuchMethodException("onVariants with 2 parameters (Action interface) not found") + val actionType = onVariantsMethod.parameterTypes[1] + + // Runs for every variant (including debug), so sentry.options.json is packaged in all builds. + onVariantsMethod.invoke( + androidComponents, + allSelector, + java.lang.reflect.Proxy.newProxyInstance( + actionType.classLoader, + arrayOf(actionType), + ) { _, _, args -> + val variant = args?.getOrNull(0) + if (variant != null) { + wireSentryOptionsAssets(variant) + } + null + }, + ) + } catch (e: Exception) { + project.logger.info( + "[sentry] Variant assets API unavailable (${e.message}); using sourceSets fallback for sentry.options.json.", + ) + applySentryOptionsSourceSetFallback() } } @@ -802,19 +925,10 @@ fun processVariant(v: Any) { } project.afterEvaluate { - // Resolve the (overridable) closure now, after the app build.gradle has evaluated, so an - // override placed after `apply from` is honored. The Property is read by the copy/cleanup - // tasks' onlyIf at execution time without touching `project` (Configuration Cache safe). - copyOptionsFileEnabled.set(shouldCopySentryOptionsFile()) - tasks.named("preBuild").configure { - dependsOn("copySentryJsonConfiguration") - } - tasks - .matching { task -> - task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") - }.configureEach { - finalizedBy("cleanupTemporarySentryJsonConfiguration") - } + // Resolve the overridable closure now (after the app build.gradle evaluated) so an override placed + // after `apply from` is honored, and set it on the task for its onlyIf to read. + val optionsCopyEnabled = shouldCopySentryOptionsFile() + generateSentryOptionsTask.configure { copyEnabled.set(optionsCopyEnabled) } val flavorAware = config["flavorAware"] == true val sentryProperties = config["sentryProperties"] From 4d7e879b962a6e0ea47980b95dbdc7cb8efdefda Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 09:37:13 +0200 Subject: [PATCH 2/4] docs(changelog): Reference PR #6751 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0e8ccbe7..92eb98e1d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Declare optional peer dependencies so imports resolve under strict and Plug'n'Play package managers ([#6729](https://github.com/getsentry/sentry-react-native/pull/6729)) - Honor `shutdownTimeout` on iOS ([#6749](https://github.com/getsentry/sentry-react-native/pull/6749)) -- Android Gradle plugin no longer writes generated `sentry.options.json` into your source tree during builds ([#6750](https://github.com/getsentry/sentry-react-native/issues/6750)) +- Android Gradle plugin no longer writes generated `sentry.options.json` into your source tree during builds ([#6751](https://github.com/getsentry/sentry-react-native/pull/6751)) ### Internal From e8557335b2f28497accf9875d2e72fe605408c16 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 10:33:09 +0200 Subject: [PATCH 3/4] fix(android): Wire lint tasks to generateSentryOptions and clear opt-out output The lint model/analysis tasks read the generated assets dir without a declared dependency on generateSentryOptions, which Gradle 9 fails as an implicit dependency error. Declare it explicitly. Also make the SENTRY_COPY_OPTIONS_FILE opt-out an @Input instead of onlyIf, so disabling it re-runs the task and clears the output dir rather than leaving a stale generated file to be packaged. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 03f9d1e22f..04735f0941 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -7,7 +7,6 @@ import org.gradle.api.provider.Property import org.gradle.api.tasks.Exec 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.PathSensitive @@ -117,9 +116,9 @@ abstract class GenerateSentryOptionsTask : DefaultTask() { @get:OutputDirectory abstract val outputDir: DirectoryProperty - // Drives `onlyIf` (the `SENTRY_COPY_OPTIONS_FILE` opt-out). Task-owned so the spec references only - // the task, not the script object (Configuration Cache safe). `@Internal`: gates execution, not content. - @get:Internal + // The `SENTRY_COPY_OPTIONS_FILE` opt-out. `@Input` (not `onlyIf`) so toggling it re-runs the task, + // which clears the output dir when disabled — a skipped task would leave a stale file to be packaged. + @get:Input abstract val copyEnabled: Property @TaskAction @@ -127,11 +126,17 @@ abstract class GenerateSentryOptionsTask : DefaultTask() { val outDir = outputDir.get().asFile outDir.mkdirs() val dest = File(outDir, "sentry.options.json") - // Idempotent: clear any prior output so a removed source file leaves an empty dir. + // Idempotent: clear any prior output so a removed source file, or a disabled opt-out, leaves an + // empty dir rather than packaging a stale file. if (dest.exists()) { dest.delete() } + if (!copyEnabled.get()) { + logger.info("sentry.options.json generation disabled via SENTRY_COPY_OPTIONS_FILE; output left empty") + return + } + val source = sourceOptionsFiles.files.firstOrNull { it.exists() } if (source == null) { logger.warn("sentry.options.json not found in app root; generated assets directory left empty") @@ -209,9 +214,8 @@ val sentryOptionsDist: String? = System.getenv("SENTRY_DIST") val generateSentryOptionsTask = tasks.register("generateSentryOptions", GenerateSentryOptionsTask::class.java) { - // onlyIf references only the task (Configuration Cache safe); opt-out resolved in afterEvaluate. + // Opt-out is a task input resolved in afterEvaluate; the action clears output when disabled. copyEnabled.convention(true) - onlyIf { (it as GenerateSentryOptionsTask).copyEnabled.get() } val appRoot = rootDirFile.parentFile ?: rootDirFile sourceOptionsFiles.from(File(appRoot, configFile)) sentryOptionsEnvironment?.let { environmentOverride.set(it) } @@ -302,6 +306,13 @@ plugins.withId("com.android.application") { ) applySentryOptionsSourceSetFallback() } + + // AGP wires `merge*Assets` to `generateSentryOptions` via the generated-source API, but the lint + // model/analysis tasks also read the generated assets dir without a declared dependency, which + // Gradle 9 fails on. Declare it explicitly so the file is always produced before they run. + tasks + .matching { it.name != "generateSentryOptions" && it.name.contains("lint", ignoreCase = true) } + .configureEach { dependsOn(generateSentryOptionsTask) } } data class BundleTaskArgs( From 1746faad8ddda20a29abc6c9474c2abf970b17cf Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 18 Sep 2026 11:03:00 +0200 Subject: [PATCH 4/4] fix(android): Warn on stale sentry.options.json left in src/main/assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older plugin versions copied the file into src/main/assets; a crashed build could leave it behind, where it now shadows or conflicts with the generated copy. Emit a configuration-time warning pointing the user to remove it. Never delete it automatically — the file may be intentional. Co-Authored-By: Claude Opus 4.8 --- packages/core/sentry.gradle.kts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 04735f0941..89903f6ade 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -224,6 +224,17 @@ val generateSentryOptionsTask = outputDir.set(sentryOptionsGeneratedDir) } +// Older plugin versions copied the file into src/main/assets and a crashed build could leave it +// behind. It would now shadow or clash with the generated one. Warn (never delete — it may be +// intentional) so the user can remove the stale copy. +val legacyOptionsFile = File(project.projectDir, "src/main/assets/$configFile") +if (legacyOptionsFile.exists()) { + project.logger.warn( + "[sentry] Found a stale $configFile in src/main/assets; it is now generated into the build " + + "folder and the old copy may conflict. Please remove: ${legacyOptionsFile.absolutePath}", + ) +} + // Guards the classic source-set fallback so it registers at most once, only when the variant API is absent. val sentryOptionsSourceSetFallbackApplied = AtomicBoolean(false)