From 5a227d50f1b60d15745ecb1bc37892a7e604fc7a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 15:16:59 +0200 Subject: [PATCH 1/2] refactor(android): Convert sentry.gradle to Kotlin DSL (sentry.gradle.kts) Convert the Groovy build script to Kotlin DSL for type safety and better IDE support. Remove legacy RN < 0.71 code paths. Auto-migrate old sentry.gradle references in Expo plugin. Closes #2327 Co-Authored-By: Claude Opus 4.6 --- .github/file-filters.yml | 4 +- .../rn.patch.app.build.gradle.js | 2 +- packages/core/plugin/src/withSentryAndroid.ts | 9 +- packages/core/sentry.gradle | 588 ----------------- packages/core/sentry.gradle.kts | 593 ++++++++++++++++++ .../expo-plugin/modifyAppBuildGradle.test.ts | 15 +- .../test/tools/sentryExpoNativeCheck.test.ts | 2 +- .../TestAppSentry/android/app/build.gradle | 2 +- samples/react-native/android/app/build.gradle | 2 +- 9 files changed, 619 insertions(+), 598 deletions(-) delete mode 100644 packages/core/sentry.gradle create mode 100644 packages/core/sentry.gradle.kts diff --git a/.github/file-filters.yml b/.github/file-filters.yml index 411669095d..523fb46c4b 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -15,7 +15,7 @@ high_risk_code: &high_risk_code # Source Maps and Native Debug Files Autoupload - 'scripts/sentry-xcode.sh' - 'scripts/sentry-xcode-debug-files.sh' - - 'sentry.gradle' + - 'sentry.gradle.kts' # --- Platform-specific filters for CI optimization --- # Used by detect-changes.yml to skip platform-irrelevant CI jobs. @@ -32,7 +32,7 @@ ios_native: android_native: - 'packages/core/android/**' - 'packages/core/RNSentryAndroidTester/**' - - 'packages/core/sentry.gradle' + - 'packages/core/sentry.gradle.kts' # Changes to JS/TS source code (affects ALL platforms) js_source: diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.build.gradle.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.build.gradle.js index c6ae9712af..e24012c2b8 100755 --- a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.build.gradle.js +++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.build.gradle.js @@ -15,7 +15,7 @@ if (!args['app-build-gradle']) { debug.log('Patching app/build.gradle', args['app-build-gradle']); const sentryGradlePatch = ` -apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle") +apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle.kts") `; const reactNativeGradleRex = /^android {/m; diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index a9276d4c3c..850a5b1476 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -40,10 +40,15 @@ const resolveSentryReactNativePackageJsonPath = * adding the relevant @sentry/react-native script. */ export function modifyAppBuildGradle(buildGradle: string): string { - if (buildGradle.includes('sentry.gradle')) { + if (buildGradle.includes('sentry.gradle.kts')) { return buildGradle; } + // Migrate old sentry.gradle references to sentry.gradle.kts + if (buildGradle.includes('sentry.gradle')) { + return buildGradle.replace(/sentry\.gradle(?!\.kts)/g, 'sentry.gradle.kts'); + } + // Use the same location that sentry-wizard uses // See: https://github.com/getsentry/sentry-wizard/blob/e9b4522f27a852069c862bd458bdf9b07cab6e33/lib/Steps/Integrations/ReactNative.ts#L232 const pattern = /^android {/m; @@ -55,7 +60,7 @@ export function modifyAppBuildGradle(buildGradle: string): string { return buildGradle; } - const applyFrom = `apply from: new File(${resolveSentryReactNativePackageJsonPath}, "sentry.gradle")`; + const applyFrom = `apply from: new File(${resolveSentryReactNativePackageJsonPath}, "sentry.gradle.kts")`; return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`); } diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle deleted file mode 100644 index 9b99dab531..0000000000 --- a/packages/core/sentry.gradle +++ /dev/null @@ -1,588 +0,0 @@ -import org.apache.tools.ant.taskdefs.condition.Os - -import java.util.regex.Matcher -import java.util.regex.Pattern - -project.ext.shouldSentryAutoUploadNative = { -> - return System.getenv('SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD') != 'true' -} - -project.ext.shouldSentryAutoUploadGeneral = { -> - return System.getenv('SENTRY_DISABLE_AUTO_UPLOAD') != 'true' -} - -project.ext.shouldSentryAutoUpload = { -> - return shouldSentryAutoUploadGeneral() && shouldSentryAutoUploadNative() -} - -interface InjectedExecOps { - @Inject //@javax.inject.Inject - ExecOperations getExecOps() -} - -project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true - return System.getenv('SENTRY_COPY_OPTIONS_FILE') != 'false' -} - -def config = project.hasProperty("sentryCli") ? project.sentryCli : []; - -def configFile = "sentry.options.json" // Sentry configuration file -def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder - -tasks.register("copySentryJsonConfiguration") { - onlyIf { shouldCopySentryOptionsFile() } - doLast { - def appRoot = project.rootDir.parentFile ?: project.rootDir - def sentryOptionsFile = new File(appRoot, configFile) - if (sentryOptionsFile.exists()) { - if (!androidAssetsDir.exists()) { - androidAssetsDir.mkdirs() - } - - copy { - from sentryOptionsFile - into androidAssetsDir - rename { String fileName -> configFile } - } - - def sentryEnv = System.getenv('SENTRY_ENVIRONMENT') - def sentryRelease = System.getenv('SENTRY_RELEASE') - def sentryDist = System.getenv('SENTRY_DIST') - if (sentryEnv || sentryRelease || sentryDist) { - try { - def destFile = new File(androidAssetsDir, configFile) - def content = new groovy.json.JsonSlurper().parseText(destFile.text) - if (sentryEnv) { content.environment = sentryEnv } - if (sentryRelease) { content.release = sentryRelease } - if (sentryDist) { content.dist = sentryDist } - destFile.text = groovy.json.JsonOutput.toJson(content) - if (sentryEnv) { logger.lifecycle("Overriding 'environment' from SENTRY_ENVIRONMENT environment variable") } - if (sentryRelease) { logger.lifecycle("Overriding 'release' from SENTRY_RELEASE environment variable") } - if (sentryDist) { logger.lifecycle("Overriding 'dist' from SENTRY_DIST environment variable") } - } catch (Exception e) { - logger.warn("Failed to override options in ${configFile}: ${e.message}. Copied file as-is.") - } - } - logger.lifecycle("Copied ${configFile} to Android assets") - } else { - logger.warn("${configFile} not found in app root (${appRoot})") - } - } -} - -tasks.register("cleanupTemporarySentryJsonConfiguration") { - onlyIf { shouldCopySentryOptionsFile() } - doLast { - def sentryOptionsFile = new File(androidAssetsDir, configFile) - if (sentryOptionsFile.exists()) { - logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") - sentryOptionsFile.delete() - } - } -} - -plugins.withId('com.android.application') { - def androidComponents = extensions.getByName("androidComponents") - - androidComponents.onVariants(androidComponents.selector().all()) { v -> - if (!v.name.toLowerCase().contains("debug")) { - // Hook into the bundle task of react native to inject sourcemap generation parameters. - // tasks.names.contains() checks task existence without iterating the container, avoiding - // eager realization of unrelated tasks (fixes #5698, Fullstory AGP Artifacts API). - def variantCapitalized = v.name.capitalize() - def sentryBundleTaskName = ["createBundle${variantCapitalized}JsAndAssets", "bundle${variantCapitalized}JsAndAssets"].find { tasks.names.contains(it) } - if (sentryBundleTaskName == null) { - project.logger.warn("[sentry] No bundle task found for variant '${v.name}'. " + - "Expected 'createBundle${variantCapitalized}JsAndAssets' or " + - "'bundle${variantCapitalized}JsAndAssets'. Source maps will not be uploaded.") - return - } - def bundleTask = tasks.named(sentryBundleTaskName).get() - if (bundleTask.enabled) { - def shouldCleanUp - def sourcemapOutput - def bundleOutput - def packagerSourcemapOutput - def bundleCommand - def props = bundleTask.getProperties() - def reactRoot = props.get("workingDir") - if (reactRoot == null) { - reactRoot = props.get("root").get() // RN 0.71 and above - } - def modulesOutput = "$reactRoot/android/app/src/main/assets/modules.json" - def modulesTask = null - - (shouldCleanUp, bundleOutput, sourcemapOutput, packagerSourcemapOutput, bundleCommand) = forceSourceMapOutputFromBundleTask(bundleTask) - - // Lets leave this here if we need to debug - // println bundleTask.properties - // .sort{it.key} - // .collect{it} - // .findAll{!['class', 'active'].contains(it.key)} - // .join('\n') - - def currentVariants = extractCurrentVariants(bundleTask, v) - if (currentVariants == null) return - - def previousCliTask = null - def applicationVariant = null - - def nameCleanup = "${bundleTask.name}_SentryUploadCleanUp" - def nameModulesCleanup = "${bundleTask.name}_SentryCollectModulesCleanUp" - // Upload the source map several times if necessary: once for each release and versionCode. - currentVariants.each { key, currentVariant -> - def variant = currentVariant[0] - def releaseName = currentVariant[1] - def versionCode = currentVariant[2] - applicationVariant = currentVariant[3] - - try { - if (versionCode instanceof String) { - versionCode = Integer.parseInt(versionCode) - versionCode = Math.abs(versionCode) - } - } catch (NumberFormatException e) { - project.logger.info("versionCode: '$versionCode' isn't an Integer, using the plain value.") - } - - // The Sentry server distinguishes source maps by release (`--release` in the command - // below) and distribution identifier (`--dist` below). Give the task a unique name - // based on where we're uploading to. - def nameCliTask = "${bundleTask.name}_SentryUpload_${releaseName}_${versionCode}" - def nameModulesTask = "${bundleTask.name}_SentryCollectModules_${releaseName}_${versionCode}" - - // If several outputs have the same releaseName and versionCode, we'd do the exact same - // upload for each of them. No need to repeat. - try { tasks.named(nameCliTask); return } catch (Exception e) {} - - /** Upload source map file to the sentry server via CLI call. */ - def cliTask = tasks.register(nameCliTask) { - onlyIf { shouldSentryAutoUploadGeneral() } - description = "upload debug symbols to sentry" - group = 'sentry.io' - - def extraArgs = [] - - def sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) - def copyDebugIdScript = config.copyDebugIdScript - ? file(config.copyDebugIdScript).getAbsolutePath() - : "$sentryPackage/scripts/copy-debugid.js" - def hasSourceMapDebugIdScript = config.hasSourceMapDebugIdScript - ? file(config.hasSourceMapDebugIdScript).getAbsolutePath() - : "$sentryPackage/scripts/has-sourcemap-debugid.js" - - def injected = project.objects.newInstance(InjectedExecOps) - doFirst { - // Copy Debug ID from packager source map to Hermes composed source map - injected.execOps.exec { - def args = ["node", - copyDebugIdScript, - packagerSourcemapOutput, - sourcemapOutput] - def osCompatibilityCopyCommand = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c'] : [] - commandLine(*osCompatibilityCopyCommand, *args) - } - - // Add release and dist for backward compatibility if no Debug ID detected in output soruce map - def process = ["node", hasSourceMapDebugIdScript, sourcemapOutput].execute(null, new File("$reactRoot")) - def exitValue = process.waitFor() - project.logger.lifecycle("Check generated source map for Debug ID: ${process.text}") - - project.logger.lifecycle("Sentry Source Maps upload will include the release name and dist.") - extraArgs.addAll([ - "--release", releaseName, - "--dist", versionCode - ]) - } - - doLast { - injected.execOps.exec { - workingDir reactRoot - - def propertiesFile = config.sentryProperties - ? config.sentryProperties - : "$reactRoot/android/sentry.properties" - - if (config.flavorAware) { - propertiesFile = "$reactRoot/android/sentry-${variant}.properties" - project.logger.info("For $variant using: $propertiesFile") - } else { - environment("SENTRY_PROPERTIES", propertiesFile) - } - - Properties sentryProps = new Properties() - try { - sentryProps.load(new FileInputStream(propertiesFile)) - } catch (FileNotFoundException e) { - if (config.flavorAware) { - throw new GradleException( - "Sentry: expected properties file not found for variant '${variant}': ${propertiesFile}. " + - "Create it, or disable 'flavorAware' in project.ext.sentryCli.") - } - project.logger.info("file not found '$propertiesFile' for '$variant'") - } - - def sentryUrl = sentryProps.get("defaults.url") - def sentryAuthToken = sentryProps.get("auth.token") ?: System.getenv("SENTRY_AUTH_TOKEN") - def sentryOrg = sentryProps.get("defaults.org") - def sentryProject = sentryProps.get("defaults.project") - - if (config.flavorAware) { - def missing = [] - if (!sentryAuthToken) missing << "auth.token (or SENTRY_AUTH_TOKEN env var)" - if (!sentryOrg) missing << "defaults.org" - if (!sentryProject) missing << "defaults.project" - if (!missing.isEmpty()) { - throw new GradleException( - "Sentry: missing required properties in '${propertiesFile}' for variant '${variant}':\n" + - " - " + missing.join("\n - ")) - } - } - - def cliPackage = resolveSentryCliPackagePath(reactRoot) - def cliExecutable = sentryProps.get("cli.executable", "$cliPackage/bin/sentry-cli") - - // fix path separator for Windows - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - cliExecutable = cliExecutable.replaceAll("/", "\\\\") - } - - // - // based on: - // https://github.com/getsentry/sentry-cli/blob/master/src/commands/react_native_gradle.rs - // - def args = [cliExecutable] - - args.addAll(!config.logLevel ? [] : [ - "--log-level", config.logLevel // control verbosity of the output - ]) - if (config.flavorAware) { - if (sentryUrl) { - args.addAll(["--url", sentryUrl]) - } - args.addAll(["--auth-token", sentryAuthToken]) - } - args.addAll(["react-native", "gradle", - "--bundle", bundleOutput, // The path to a bundle that should be uploaded. - "--sourcemap", sourcemapOutput // The path to a sourcemap that should be uploaded. - ]) - if (config.flavorAware) { - args.addAll([ - "--org", sentryOrg, - "--project", sentryProject - ]) - } - - args.addAll(extraArgs) - - // Mask sentryAuthToken in the logged args; do not pass loggedArgs to the CLI. - def loggedArgs = sentryAuthToken ? args.collect { it == sentryAuthToken ? "***" : it } : args - project.logger.lifecycle("Sentry-CLI arguments: ${loggedArgs}") - def osCompatibility = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'node'] : [] - if (!System.getenv('SENTRY_DOTENV_PATH') && file("$reactRoot/.env.sentry-build-plugin").exists()) { - environment('SENTRY_DOTENV_PATH', "$reactRoot/.env.sentry-build-plugin") - } - commandLine(*osCompatibility, *args) - } - } - - enabled true - } - - modulesTask = tasks.register(nameModulesTask, Exec) { - description = "collect javascript modules from bundle source map" - group = 'sentry.io' - - workingDir reactRoot - - def sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) - - def collectModulesScript = config.collectModulesScript - ? file(config.collectModulesScript).getAbsolutePath() - : "$sentryPackage/dist/js/tools/collectModules.js" - def modulesPaths = config.modulesPaths - ? config.modulesPaths.join(',') - : "$reactRoot/node_modules" - def args = ["node", - collectModulesScript, - sourcemapOutput, - modulesOutput, - modulesPaths - ] - - if ((new File(collectModulesScript)).exists()) { - project.logger.info("Sentry-CollectModules arguments: ${args}") - commandLine(*args) - - def skip = config.skipCollectModules - ? config.skipCollectModules == true - : false - enabled !skip - } else { - project.logger.info("collectModulesScript not found: $collectModulesScript") - enabled false - } - } - - // chain the upload tasks so they run sequentially in order to run - // the cliCleanUpTask after the final upload task is run - if (previousCliTask != null) { - previousCliTask.configure { finalizedBy cliTask } - } else { - bundleTask.configure { finalizedBy cliTask } - } - previousCliTask = cliTask - cliTask.configure { finalizedBy modulesTask } - } - - def modulesCleanUpTask = tasks.register(nameModulesCleanup, Delete) { - description = "clean up collected modules generated file" - group = 'sentry.io' - - delete modulesOutput - } - - /** Delete sourcemap files */ - def cliCleanUpTask = tasks.register(nameCleanup, Delete) { - description = "clean up extra sourcemap" - group = 'sentry.io' - - delete sourcemapOutput - delete "$buildDir/intermediates/assets/release/index.android.bundle.map" - // react native default bundle dir - } - - // register clean task extension - cliCleanUpTask.configure { onlyIf { shouldCleanUp } } - // due to chaining the last value of previousCliTask will be the final - // upload task, after which the cleanup can be done - previousCliTask.configure { finalizedBy cliCleanUpTask } - - def packageTasks = tasks.matching { - task -> ("package${applicationVariant}".equalsIgnoreCase(task.name) || "package${applicationVariant}Bundle".equalsIgnoreCase(task.name)) && task.enabled - } - packageTasks.configureEach { packageTask -> - packageTask.dependsOn modulesTask - packageTask.finalizedBy modulesCleanUpTask - } - } - } - } -} - -// gradle.projectsEvaluated doesn't work with --configure-on-demand -// the task are create too late and not executed -project.afterEvaluate { - // Add a task that copies the sentry.options.json file before the build starts - tasks.named("preBuild").configure { - dependsOn("copySentryJsonConfiguration") - } - // Cleanup sentry.options.json from assets after the build - tasks.matching { task -> - task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") - }.configureEach { - finalizedBy("cleanupTemporarySentryJsonConfiguration") - } - - if (config.flavorAware && config.sentryProperties) { - throw new GradleException("Incompatible sentry configuration. " + - "You cannot use both `flavorAware` and `sentryProperties`. " + - "Please remove one of these from the project.ext.sentryCli configuration.") - } - - if (config.sentryProperties instanceof String) { - config.sentryProperties = file(config.sentryProperties) - } - - if (config.sentryProperties) { - if (!config.sentryProperties.exists()) { - throw new GradleException("project.ext.sentryCli configuration defines a non-existant 'sentryProperties' file: " + config.sentryProperties.getAbsolutePath()) - } - logger.info("Using 'sentry.properties' at: " + config.sentryProperties.getAbsolutePath()) - } - - if (config.flavorAware) { - println "**********************************" - println "* Flavor aware sentry properties *" - println "**********************************" - } -} - -def resolveSentryReactNativeSDKPath(reactRoot) { - def resolvedSentryPath = null - try { - resolvedSentryPath = new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile(); - } catch (Throwable ignored) {} // if the resolve fails we fallback to the default path - def sentryPackage = resolvedSentryPath != null && resolvedSentryPath.exists() ? resolvedSentryPath.getAbsolutePath() : "$reactRoot/node_modules/@sentry/react-native" - return sentryPackage -} - -def resolveSentryCliPackagePath(reactRoot) { - def resolvedCliPath = null - try { - resolvedCliPath = new File(["node", "--print", "require.resolve('@sentry/cli/package.json')"].execute(null, rootDir).text.trim()).getParentFile(); - } catch (Throwable ignored) { // Check if it's located in .pnpm - try { - def pnpmRefPath = reactRoot.toString() + "/node_modules/@sentry/react-native/node_modules/.bin/sentry-cli" - def sentryCliFile = new File(pnpmRefPath) - - if (sentryCliFile.exists()) { - def cliFileText = sentryCliFile.text - def matcher = cliFileText =~ /NODE_PATH="([^"]*?)@sentry\/cli\// - - if (matcher.find()) { - def match = matcher.group(1) - resolvedCliPath = new File(match + "@sentry/cli") - } - } - } catch (Throwable ignored2) {} // if the resolve fails we fallback to the default path - } - - def cliPackage = resolvedCliPath != null && resolvedCliPath.exists() ? resolvedCliPath.getAbsolutePath() : "$reactRoot/node_modules/@sentry/cli" - return cliPackage -} - -/** Extract from arguments collection bundle and sourcemap files output names. */ -static extractBundleTaskArgumentsLegacy(cmdArgs, Project project) { - def bundleOutput = null - def sourcemapOutput = null - def packagerSourcemapOutput = null - // packagerBundleOutput doesn't exist, because packager output is overwritten by Hermes - - cmdArgs.eachWithIndex { String arg, int i -> - if (arg == "--bundle-output") { - bundleOutput = cmdArgs[i + 1] - project.logger.info("--bundle-output: `${bundleOutput}`") - } else if (arg == "--sourcemap-output") { - sourcemapOutput = cmdArgs[i + 1] - packagerSourcemapOutput = sourcemapOutput - project.logger.info("--sourcemap-output param: `${sourcemapOutput}`") - } - } - - // Best thing would be if we just had access to the local gradle variables here: - // https://github.com/facebook/react-native/blob/ff3b839e9a5a6c9e398a1327cde6dd49a3593092/react.gradle#L89-L97 - // Now, the issue is that hermes builds have a different pipeline: - // `metro -> hermes -> compose-source-maps`, which then combines both intermediate sourcemaps into the final one. - // In this function here, we only grep through the first `metro` step, which only generates an intermediate sourcemap, - // which is wrong. We need the final one. Luckily, we can just generate the path from the `bundleOutput`, since - // the paths seem to be well defined. - - // if sourcemapOutput is null, it means there's no source maps at all - // if hermes is enabled and has intermediates folder, we need to fix paths - // if hermes is disabled, sourcemapOutput is already ok - def enableHermes = project.ext.react.get("enableHermes", false); - project.logger.info("enableHermes: `${enableHermes}`") - - if (bundleOutput != null && sourcemapOutput != null && enableHermes) { - // react-native < 0.60.1 - def pattern = Pattern.compile("(/|\\\\)intermediates\\1sourcemaps\\1react\\1") - Matcher matcher = pattern.matcher(sourcemapOutput) - // if its intermediates/sourcemaps/react then it should be generated/sourcemaps/react - if (matcher.find()) { - project.logger.info("sourcemapOutput has the wrong path, let's fix it.") - // replacing from bundleOutput which is more reliable - sourcemapOutput = bundleOutput.replaceAll("(/|\\\\)generated\\1assets\\1react\\1", "\$1generated\$1sourcemaps\$1react\$1") + ".map" - project.logger.info("sourcemapOutput new path: `${sourcemapOutput}`") - } - } - - // get the current bundle command, if not peresent use default plain "bundle" - // we use this later to decide how to upload source maps - def bundleCommand = project.ext.react.get("bundleCommand", "bundle") - - return [bundleOutput, sourcemapOutput, packagerSourcemapOutput, bundleCommand] -} - -/** Extract bundle and sourcemap paths from bundle task props. - * Based on https://github.dev/facebook/react-native/blob/473eb1dd870a4f62c4ebcba27e12bde1e99e3d07/packages/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/BundleHermesCTask.kt#L109 - * Output source map path is the same for both Hermes and JSC. - */ -static extractBundleTaskArgumentsRN71AndAbove(bundleTask, logger) { - def props = bundleTask.getProperties() - def bundleAssetName = props.bundleAssetName?.get() - - if (bundleAssetName == null) { - return [null, null] - } - - def bundleCommand = props.bundleCommand.get() - def bundleFile = new File(props.jsBundleDir.get().asFile.absolutePath, bundleAssetName) - def outputSourceMap = new File(props.jsSourceMapsDir.get().asFile.absolutePath, "${bundleAssetName}.map") - def packagerOutputSourceMap = new File(props.jsIntermediateSourceMapsDir.get().asFile.absolutePath, "${bundleAssetName}.packager.map") - - logger.info("bundleFile: `${bundleFile}`") - logger.info("outputSourceMap: `${outputSourceMap}`") - logger.info("packagerOutputSourceMap: `${packagerOutputSourceMap}`") - return [bundleFile, outputSourceMap, packagerOutputSourceMap, bundleCommand] -} - -/** Force Bundle task to produce sourcemap files if they are not pre-configured by user yet. */ -def forceSourceMapOutputFromBundleTask(bundleTask) { - def props = bundleTask.getProperties() - def cmd = props.get("commandLine") as List - def cmdArgs = props.get("args") as List - def shouldCleanUp = false - def bundleOutput = null - def sourcemapOutput = null - def packagerSourcemapOutput = null - def bundleCommand = null - - (bundleOutput, sourcemapOutput, packagerSourcemapOutput, bundleCommand) = extractBundleTaskArgumentsRN71AndAbove(bundleTask, logger) - if (bundleOutput == null) { - (bundleOutput, sourcemapOutput, packagerSourcemapOutput, bundleCommand) = extractBundleTaskArgumentsLegacy(cmdArgs, project) - } - - if (sourcemapOutput == null) { - sourcemapOutput = bundleOutput + ".map" - - cmd.addAll(["--sourcemap-output", sourcemapOutput]) - cmdArgs.addAll(["--sourcemap-output", sourcemapOutput]) - - shouldCleanUp = true - - bundleTask.setProperty("commandLine", cmd) - bundleTask.setProperty("args", cmdArgs) - - project.logger.info("forced sourcemap file output for `${bundleTask.name}` task") - } else { - project.logger.info("Info: used pre-configured source map files: ${sourcemapOutput}") - } - - return [shouldCleanUp, bundleOutput, sourcemapOutput, packagerSourcemapOutput, bundleCommand] -} - -/** compose array with one item - current build flavor name */ -static extractCurrentVariants(bundleTask, variant) { - // examples: bundleLocalReleaseJsAndAssets, createBundleYellowDebugJsAndAssets - def pattern = Pattern.compile("(?:create)?(?:B|b)undle([A-Z][A-Za-z0-9_]+)JsAndAssets") - - def currentRelease = "" - - Matcher matcher = pattern.matcher(bundleTask.name) - if (matcher.find()) { - def match = matcher.group(1) - currentRelease = match.substring(0, 1).toLowerCase() + match.substring(1) - } - - def currentVariants = null - if (variant.name.equalsIgnoreCase(currentRelease)) { - currentVariants = [:] - def variantName = variant.name - variant.outputs.each { output -> - def defaultVersionCode = output.versionCode.getOrElse(0) - def versionCode = System.getenv('SENTRY_DIST') ?: defaultVersionCode - def appId = variant.applicationId.get() - def versionName = output.versionName.getOrElse('') // may be empty if not set - def defaultReleaseName = "${appId}@${versionName}+${versionCode}" - def releaseName = System.getenv('SENTRY_RELEASE') ?: defaultReleaseName - - def outputName = output.baseName - - if (currentVariants[outputName] == null) currentVariants[outputName] = [] - currentVariants[outputName] = [outputName, releaseName, versionCode, variantName] - } - } - - return currentVariants -} diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts new file mode 100644 index 0000000000..a75f4ba751 --- /dev/null +++ b/packages/core/sentry.gradle.kts @@ -0,0 +1,593 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import java.io.FileInputStream +import java.util.Properties +import java.util.regex.Pattern +import javax.inject.Inject +import org.gradle.api.tasks.Exec + +val shouldSentryAutoUploadNative: () -> Boolean = { + System.getenv("SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD") != "true" +} + +val shouldSentryAutoUploadGeneral: () -> Boolean = { + System.getenv("SENTRY_DISABLE_AUTO_UPLOAD") != "true" +} + +val shouldSentryAutoUpload: () -> Boolean = { + shouldSentryAutoUploadGeneral() && shouldSentryAutoUploadNative() +} + +extra["shouldSentryAutoUploadNative"] = object : groovy.lang.Closure(this) { + fun doCall(): Boolean = shouldSentryAutoUploadNative() +} +extra["shouldSentryAutoUploadGeneral"] = object : groovy.lang.Closure(this) { + fun doCall(): Boolean = shouldSentryAutoUploadGeneral() +} +extra["shouldSentryAutoUpload"] = object : groovy.lang.Closure(this) { + fun doCall(): Boolean = shouldSentryAutoUpload() +} + +interface InjectedExecOps { + @get:Inject + val execOps: org.gradle.process.ExecOperations +} + +val shouldCopySentryOptionsFile: () -> Boolean = { + System.getenv("SENTRY_COPY_OPTIONS_FILE") != "false" +} + +extra["shouldCopySentryOptionsFile"] = object : groovy.lang.Closure(this) { + fun doCall(): Boolean = shouldCopySentryOptionsFile() +} + +@Suppress("UNCHECKED_CAST") +val config: Map = if (project.hasProperty("sentryCli")) { + project.property("sentryCli") as Map +} else { + emptyMap() +} + +val configFile = "sentry.options.json" +val androidAssetsDir = File("${rootDir}/app/src/main/assets") + +tasks.register("copySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + val appRoot = project.rootDir.parentFile ?: project.rootDir + val sentryOptionsFile = File(appRoot, configFile) + if (sentryOptionsFile.exists()) { + if (!androidAssetsDir.exists()) { + androidAssetsDir.mkdirs() + } + + copy { + from(sentryOptionsFile) + into(androidAssetsDir) + rename { configFile } + } + + 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.") + } + } + logger.lifecycle("Copied $configFile to Android assets") + } else { + logger.warn("$configFile not found in app root ($appRoot)") + } + } +} + +tasks.register("cleanupTemporarySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + val sentryOptionsFile = File(androidAssetsDir, configFile) + if (sentryOptionsFile.exists()) { + logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") + sentryOptionsFile.delete() + } + } +} + +data class BundleTaskArgs( + val bundleOutput: File?, + val sourcemapOutput: File?, + val packagerSourcemapOutput: File?, + val bundleCommand: String?, +) + +fun resolveSentryReactNativeSDKPath(reactRoot: File): String { + var resolvedSentryPath: File? = null + try { + val output = ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/react-native/package.json')")) + .directory(rootDir) + .start() + .inputStream.bufferedReader().readText().trim() + resolvedSentryPath = File(output).parentFile + } catch (_: Throwable) {} + return if (resolvedSentryPath != null && resolvedSentryPath.exists()) { + resolvedSentryPath.absolutePath + } else { + "$reactRoot/node_modules/@sentry/react-native" + } +} + +fun resolveSentryCliPackagePath(reactRoot: File): String { + var resolvedCliPath: File? = null + try { + val output = ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/cli/package.json')")) + .directory(rootDir) + .start() + .inputStream.bufferedReader().readText().trim() + resolvedCliPath = File(output).parentFile + } catch (_: Throwable) { + try { + val pnpmRefPath = "$reactRoot/node_modules/@sentry/react-native/node_modules/.bin/sentry-cli" + val sentryCliFile = File(pnpmRefPath) + if (sentryCliFile.exists()) { + val cliFileText = sentryCliFile.readText() + val regex = Regex("""NODE_PATH="([^"]*?)@sentry/cli/""") + val match = regex.find(cliFileText) + if (match != null) { + resolvedCliPath = File(match.groupValues[1] + "@sentry/cli") + } + } + } catch (_: Throwable) {} + } + return if (resolvedCliPath != null && resolvedCliPath.exists()) { + resolvedCliPath.absolutePath + } else { + "$reactRoot/node_modules/@sentry/cli" + } +} + +fun extractBundleTaskArguments(bundleTask: Task, logger: Logger): BundleTaskArgs { + val props = bundleTask.properties + val bundleAssetName = (props["bundleAssetName"] as? org.gradle.api.provider.Provider<*>)?.orNull as? String + ?: return BundleTaskArgs(null, null, null, null) + + val bundleCommand = (props["bundleCommand"] as? org.gradle.api.provider.Provider<*>)?.get() as? String + val jsBundleDir = (props["jsBundleDir"] as? org.gradle.api.provider.Provider<*>)?.get() + val jsSourceMapsDir = (props["jsSourceMapsDir"] as? org.gradle.api.provider.Provider<*>)?.get() + val jsIntermediateSourceMapsDir = (props["jsIntermediateSourceMapsDir"] as? org.gradle.api.provider.Provider<*>)?.get() + + val bundleDirFile = when (jsBundleDir) { + is org.gradle.api.file.Directory -> jsBundleDir.asFile + else -> return BundleTaskArgs(null, null, null, null) + } + val sourcemapsDirFile = when (jsSourceMapsDir) { + is org.gradle.api.file.Directory -> jsSourceMapsDir.asFile + else -> return BundleTaskArgs(null, null, null, null) + } + val intermediateSourcemapsDirFile = when (jsIntermediateSourceMapsDir) { + is org.gradle.api.file.Directory -> jsIntermediateSourceMapsDir.asFile + else -> return BundleTaskArgs(null, null, null, null) + } + + val bundleFile = File(bundleDirFile.absolutePath, bundleAssetName) + val outputSourceMap = File(sourcemapsDirFile.absolutePath, "${bundleAssetName}.map") + val packagerOutputSourceMap = File(intermediateSourcemapsDirFile.absolutePath, "${bundleAssetName}.packager.map") + + logger.info("bundleFile: `$bundleFile`") + logger.info("outputSourceMap: `$outputSourceMap`") + logger.info("packagerOutputSourceMap: `$packagerOutputSourceMap`") + return BundleTaskArgs(bundleFile, outputSourceMap, packagerOutputSourceMap, bundleCommand) +} + +data class ForceSourceMapResult( + val shouldCleanUp: Boolean, + val bundleOutput: File?, + val sourcemapOutput: File?, + val packagerSourcemapOutput: File?, + val bundleCommand: String?, +) + +fun forceSourceMapOutputFromBundleTask(bundleTask: Task): ForceSourceMapResult { + val args = extractBundleTaskArguments(bundleTask, logger) + + if (args.bundleOutput == null || args.sourcemapOutput == null) { + logger.warn("[sentry] Could not extract bundle task arguments for '${bundleTask.name}'. Source maps will not be uploaded.") + return ForceSourceMapResult(false, null, null, null, null) + } + + logger.info("Info: used pre-configured source map files: ${args.sourcemapOutput}") + + return ForceSourceMapResult(false, args.bundleOutput, args.sourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) +} + +data class VariantInfo( + val variantName: String, + val releaseName: String, + val versionCode: Any, + val applicationVariant: String, +) + +fun extractCurrentVariants(bundleTask: Task, variant: Any): Map? { + val pattern = Pattern.compile("(?:create)?(?:B|b)undle([A-Z][A-Za-z0-9_]+)JsAndAssets") + val matcher = pattern.matcher(bundleTask.name) + + var currentRelease = "" + if (matcher.find()) { + val match = matcher.group(1) + currentRelease = match.substring(0, 1).lowercase() + match.substring(1) + } + + // Use reflection to access variant properties since AGP types are not on the script classpath + val variantName = variant.javaClass.getMethod("getName").invoke(variant) as String + + if (!variantName.equals(currentRelease, ignoreCase = true)) { + return null + } + + val currentVariants = mutableMapOf() + val applicationId = variant.javaClass.getMethod("getApplicationId").invoke(variant) + val appId = (applicationId as org.gradle.api.provider.Provider<*>).get() as String + + val outputs = variant.javaClass.getMethod("getOutputs").invoke(variant) as Iterable<*> + for (output in outputs) { + if (output == null) continue + + val versionCodeProvider = output.javaClass.getMethod("getVersionCode").invoke(output) as org.gradle.api.provider.Provider<*> + val versionNameProvider = output.javaClass.getMethod("getVersionName").invoke(output) as org.gradle.api.provider.Provider<*> + + val defaultVersionCode = versionCodeProvider.orNull ?: 0 + var versionCode: Any = System.getenv("SENTRY_DIST") ?: defaultVersionCode + if (versionCode is String) { + try { + versionCode = Math.abs(versionCode.toInt()) + } catch (_: NumberFormatException) { + project.logger.info("versionCode: '$versionCode' isn't an Integer, using the plain value.") + } + } + + val versionName = (versionNameProvider.orNull as? String) ?: "" + val defaultReleaseName = "$appId@$versionName+$versionCode" + val releaseName = System.getenv("SENTRY_RELEASE") ?: defaultReleaseName + + val outputName = output.javaClass.getMethod("getBaseName").invoke(output) as String + + currentVariants[outputName] = VariantInfo(outputName, releaseName, versionCode, variantName) + } + + return currentVariants +} + +plugins.withId("com.android.application") { + val androidComponents = extensions.getByName("androidComponents") + + val selectorMethod = androidComponents.javaClass.getMethod("selector") + val selector = selectorMethod.invoke(androidComponents) + val allMethod = selector.javaClass.getMethod("all") + val allSelector = allMethod.invoke(selector) + + val onVariantsMethod = androidComponents.javaClass.methods.find { + it.name == "onVariants" && it.parameterCount == 2 + }!! + val actionType = onVariantsMethod.parameterTypes[1] + + onVariantsMethod.invoke(androidComponents, allSelector, + java.lang.reflect.Proxy.newProxyInstance( + actionType.classLoader, + arrayOf(actionType) + ) { _, _, args -> + if (args != null && args.isNotEmpty()) { + processVariant(args[0]!!) + } + null + } + ) +} + +fun processVariant(v: Any) { + val vName = v.javaClass.getMethod("getName").invoke(v) as String + if (vName.lowercase().contains("debug")) return + + val variantCapitalized = vName.replaceFirstChar { it.uppercase() } + val sentryBundleTaskName = listOf( + "createBundle${variantCapitalized}JsAndAssets", + "bundle${variantCapitalized}JsAndAssets" + ).find { tasks.names.contains(it) } + + if (sentryBundleTaskName == null) { + project.logger.warn( + "[sentry] No bundle task found for variant '$vName'. " + + "Expected 'createBundle${variantCapitalized}JsAndAssets' or " + + "'bundle${variantCapitalized}JsAndAssets'. Source maps will not be uploaded." + ) + return + } + + val bundleTask = tasks.named(sentryBundleTaskName).get() + if (!bundleTask.enabled) return + + val result = forceSourceMapOutputFromBundleTask(bundleTask) + if (result.bundleOutput == null || result.sourcemapOutput == null) return + + val bundleOutput = result.bundleOutput + val sourcemapOutput = result.sourcemapOutput + val packagerSourcemapOutput = result.packagerSourcemapOutput + + val props = bundleTask.properties + var reactRoot: File? = props["workingDir"] as? File + if (reactRoot == null) { + val rootProvider = props["root"] as? org.gradle.api.provider.Provider<*> + reactRoot = rootProvider?.get() as? File + } + if (reactRoot == null) { + project.logger.warn("[sentry] Could not determine reactRoot for '${bundleTask.name}'.") + return + } + + val modulesOutput = "$reactRoot/android/app/src/main/assets/modules.json" + + val currentVariants = extractCurrentVariants(bundleTask, v) ?: return + + var previousCliTask: TaskProvider? = null + var applicationVariant: String? = null + val nameCleanup = "${bundleTask.name}_SentryUploadCleanUp" + val nameModulesCleanup = "${bundleTask.name}_SentryCollectModulesCleanUp" + var lastModulesTask: TaskProvider? = null + + currentVariants.forEach { (_, currentVariant) -> + val variant = currentVariant.variantName + val releaseName = currentVariant.releaseName + val versionCode = currentVariant.versionCode + applicationVariant = currentVariant.applicationVariant + + val nameCliTask = "${bundleTask.name}_SentryUpload_${releaseName}_${versionCode}" + val nameModulesTask = "${bundleTask.name}_SentryCollectModules_${releaseName}_${versionCode}" + + if (tasks.names.contains(nameCliTask)) return@forEach + + val cliTask = tasks.register(nameCliTask) { + onlyIf { shouldSentryAutoUploadGeneral() } + description = "upload debug symbols to sentry" + group = "sentry.io" + + val sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) + val copyDebugIdScript = (config["copyDebugIdScript"] as? String) + ?.let { file(it).absolutePath } + ?: "$sentryPackage/scripts/copy-debugid.js" + val hasSourceMapDebugIdScript = (config["hasSourceMapDebugIdScript"] as? String) + ?.let { file(it).absolutePath } + ?: "$sentryPackage/scripts/has-sourcemap-debugid.js" + + val injected = project.objects.newInstance(InjectedExecOps::class.java) + val extraArgs = mutableListOf() + + doFirst { + injected.execOps.exec { + val args = listOf("node", copyDebugIdScript, packagerSourcemapOutput.toString(), sourcemapOutput.toString()) + val osCompatibility = if (Os.isFamily(Os.FAMILY_WINDOWS)) listOf("cmd", "/c") else emptyList() + commandLine(osCompatibility + args) + } + + val process = ProcessBuilder(listOf("node", hasSourceMapDebugIdScript, sourcemapOutput.toString())) + .directory(reactRoot) + .start() + process.waitFor() + project.logger.lifecycle("Check generated source map for Debug ID: ${process.inputStream.bufferedReader().readText()}") + + project.logger.lifecycle("Sentry Source Maps upload will include the release name and dist.") + extraArgs.addAll(listOf("--release", releaseName, "--dist", versionCode.toString())) + } + + doLast { + injected.execOps.exec { + workingDir(reactRoot) + + var propertiesFile = (config["sentryProperties"] as? String) + ?: "$reactRoot/android/sentry.properties" + val flavorAware = config["flavorAware"] == true + + if (flavorAware) { + propertiesFile = "$reactRoot/android/sentry-${variant}.properties" + project.logger.info("For $variant using: $propertiesFile") + } else { + environment("SENTRY_PROPERTIES", propertiesFile) + } + + val sentryProps = Properties() + try { + sentryProps.load(FileInputStream(propertiesFile)) + } catch (e: java.io.FileNotFoundException) { + if (flavorAware) { + throw GradleException( + "Sentry: expected properties file not found for variant '$variant': $propertiesFile. " + + "Create it, or disable 'flavorAware' in project.ext.sentryCli." + ) + } + project.logger.info("file not found '$propertiesFile' for '$variant'") + } + + val sentryUrl = sentryProps.getProperty("defaults.url") + val sentryAuthToken = sentryProps.getProperty("auth.token") ?: System.getenv("SENTRY_AUTH_TOKEN") + val sentryOrg = sentryProps.getProperty("defaults.org") + val sentryProject = sentryProps.getProperty("defaults.project") + + if (flavorAware) { + val missing = mutableListOf() + if (sentryAuthToken == null) missing.add("auth.token (or SENTRY_AUTH_TOKEN env var)") + if (sentryOrg == null) missing.add("defaults.org") + if (sentryProject == null) missing.add("defaults.project") + if (missing.isNotEmpty()) { + throw GradleException( + "Sentry: missing required properties in '$propertiesFile' for variant '$variant':\n" + + " - " + missing.joinToString("\n - ") + ) + } + } + + val cliPackage = resolveSentryCliPackagePath(reactRoot) + var cliExecutable = sentryProps.getProperty("cli.executable") ?: "$cliPackage/bin/sentry-cli" + + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + cliExecutable = cliExecutable.replace("/", "\\") + } + + val args = mutableListOf(cliExecutable) + + val logLevel = config["logLevel"] as? String + if (logLevel != null) { + args.addAll(listOf("--log-level", logLevel)) + } + if (flavorAware) { + if (sentryUrl != null) { + args.addAll(listOf("--url", sentryUrl)) + } + args.addAll(listOf("--auth-token", sentryAuthToken!!)) + } + args.addAll(listOf( + "react-native", "gradle", + "--bundle", bundleOutput.toString(), + "--sourcemap", sourcemapOutput.toString() + )) + if (flavorAware) { + args.addAll(listOf("--org", sentryOrg!!, "--project", sentryProject!!)) + } + + args.addAll(extraArgs) + + val loggedArgs = if (sentryAuthToken != null) { + args.map { if (it == sentryAuthToken) "***" else it } + } else { + args + } + project.logger.lifecycle("Sentry-CLI arguments: $loggedArgs") + val osCompatibility = if (Os.isFamily(Os.FAMILY_WINDOWS)) listOf("cmd", "/c", "node") else emptyList() + if (System.getenv("SENTRY_DOTENV_PATH") == null && file("$reactRoot/.env.sentry-build-plugin").exists()) { + environment("SENTRY_DOTENV_PATH", "$reactRoot/.env.sentry-build-plugin") + } + commandLine(osCompatibility + args) + } + } + + enabled = true + } + + val modulesTask = tasks.register(nameModulesTask, Exec::class.java) { + description = "collect javascript modules from bundle source map" + group = "sentry.io" + + workingDir(reactRoot) + + val sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) + + val collectModulesScript = (config["collectModulesScript"] as? String) + ?.let { file(it).absolutePath } + ?: "$sentryPackage/dist/js/tools/collectModules.js" + @Suppress("UNCHECKED_CAST") + val modulesPaths = (config["modulesPaths"] as? List) + ?.joinToString(",") + ?: "$reactRoot/node_modules" + val args = listOf("node", collectModulesScript, sourcemapOutput.toString(), modulesOutput, modulesPaths) + + if (File(collectModulesScript).exists()) { + project.logger.info("Sentry-CollectModules arguments: $args") + commandLine(args) + + val skip = config["skipCollectModules"] == true + enabled = !skip + } else { + project.logger.info("collectModulesScript not found: $collectModulesScript") + enabled = false + } + } + lastModulesTask = modulesTask + + if (previousCliTask != null) { + previousCliTask!!.configure { finalizedBy(cliTask) } + } else { + bundleTask.finalizedBy(cliTask) + } + previousCliTask = cliTask + cliTask.configure { finalizedBy(modulesTask) } + } + + val modulesCleanUpTask = tasks.register(nameModulesCleanup, Delete::class.java) { + description = "clean up collected modules generated file" + group = "sentry.io" + + delete(modulesOutput) + } + + val cliCleanUpTask = tasks.register(nameCleanup, Delete::class.java) { + description = "clean up extra sourcemap" + group = "sentry.io" + + delete(sourcemapOutput) + delete("$buildDir/intermediates/assets/release/index.android.bundle.map") + } + + cliCleanUpTask.configure { onlyIf { result.shouldCleanUp } } + previousCliTask?.configure { finalizedBy(cliCleanUpTask) } + + tasks.matching { task -> + val appVariant = applicationVariant ?: return@matching false + ("package${appVariant}".equals(task.name, ignoreCase = true) || + "package${appVariant}Bundle".equals(task.name, ignoreCase = true)) && task.enabled + }.configureEach { + if (lastModulesTask != null) { + dependsOn(lastModulesTask!!) + } + finalizedBy(modulesCleanUpTask) + } +} + +project.afterEvaluate { + tasks.named("preBuild").configure { + dependsOn("copySentryJsonConfiguration") + } + tasks.matching { task -> + task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") + }.configureEach { + finalizedBy("cleanupTemporarySentryJsonConfiguration") + } + + val flavorAware = config["flavorAware"] == true + val sentryProperties = config["sentryProperties"] + + if (flavorAware && sentryProperties != null) { + throw GradleException( + "Incompatible sentry configuration. " + + "You cannot use both `flavorAware` and `sentryProperties`. " + + "Please remove one of these from the project.ext.sentryCli configuration." + ) + } + + val sentryPropertiesFile = when (sentryProperties) { + is String -> file(sentryProperties) + is File -> sentryProperties + else -> null + } + + if (sentryPropertiesFile != null) { + if (!sentryPropertiesFile.exists()) { + throw GradleException( + "project.ext.sentryCli configuration defines a non-existent 'sentryProperties' file: " + + sentryPropertiesFile.absolutePath + ) + } + logger.info("Using 'sentry.properties' at: " + sentryPropertiesFile.absolutePath) + } + + if (flavorAware) { + println("**********************************") + println("* Flavor aware sentry properties *") + println("**********************************") + } +} diff --git a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts index 0dcc9b33d6..c152090dbc 100644 --- a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts +++ b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts @@ -4,7 +4,7 @@ import { modifyAppBuildGradle } from '../../plugin/src/withSentryAndroid'; jest.mock('../../plugin/src/logger'); const buildGradleWithSentry = ` -apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle.kts") android { } @@ -16,7 +16,7 @@ android { `; const monoRepoBuildGradleWithSentry = ` -apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle.kts") android { } @@ -27,6 +27,13 @@ android { } `; +const buildGradleWithOldSentryGradle = ` +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") + +android { +} +`; + const buildGradleWithOutReactGradleScript = ` `; @@ -47,6 +54,10 @@ describe('Configures Android native project correctly', () => { expect(modifyAppBuildGradle(monoRepoBuildGradleWithOutSentry)).toStrictEqual(monoRepoBuildGradleWithSentry); }); + it('Migrates old sentry.gradle reference to sentry.gradle.kts', () => { + expect(modifyAppBuildGradle(buildGradleWithOldSentryGradle)).toStrictEqual(buildGradleWithSentry); + }); + it('Warns to file a bug report if no react.gradle is found', () => { modifyAppBuildGradle(buildGradleWithOutReactGradleScript); expect(warnOnce).toHaveBeenCalled(); diff --git a/packages/core/test/tools/sentryExpoNativeCheck.test.ts b/packages/core/test/tools/sentryExpoNativeCheck.test.ts index b0511f2da0..9a3498bf8d 100644 --- a/packages/core/test/tools/sentryExpoNativeCheck.test.ts +++ b/packages/core/test/tools/sentryExpoNativeCheck.test.ts @@ -40,7 +40,7 @@ const PBXPROJ_WITHOUT_SENTRY = ` `; const BUILD_GRADLE_WITH_SENTRY = ` -apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle.kts") android { } diff --git a/performance-tests/TestAppSentry/android/app/build.gradle b/performance-tests/TestAppSentry/android/app/build.gradle index 1bdbb79d02..6342004692 100644 --- a/performance-tests/TestAppSentry/android/app/build.gradle +++ b/performance-tests/TestAppSentry/android/app/build.gradle @@ -72,7 +72,7 @@ def enableProguardInReleaseBuilds = false */ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' -apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle") +apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle.kts") android { ndkVersion rootProject.ext.ndkVersion buildToolsVersion rootProject.ext.buildToolsVersion diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 5f1a37b8a6..e6669ab5ae 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -13,7 +13,7 @@ project.ext.sentryCli = [ hasSourceMapDebugIdScript: "${sentryReactNativePath}/scripts/has-sourcemap-debugid.js", ] -apply from: new File("${sentryReactNativePath}", "sentry.gradle") +apply from: new File("${sentryReactNativePath}", "sentry.gradle.kts") sentry { // Whether the plugin should attempt to auto-upload the mapping file to Sentry or not. From 64495fd67a432cb4336eafcbc981dd1174713e75 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 8 May 2026 15:24:15 +0200 Subject: [PATCH 2/2] refactor(android): Add sentry.gradle shim for backward compatibility Keep a thin sentry.gradle that forwards to sentry.gradle.kts so existing wizard and manually configured projects continue to work without changes. Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/core/sentry.gradle diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle new file mode 100644 index 0000000000..cbda326b80 --- /dev/null +++ b/packages/core/sentry.gradle @@ -0,0 +1 @@ +apply from: new File(buildscript.sourceFile.parentFile, "sentry.gradle.kts")