From 47351110732c35c6688dcc18879c0f4b91e69369 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 13 May 2026 18:23:47 +0200 Subject: [PATCH 01/10] refactor(android): Convert sentry.gradle to Kotlin DSL (sentry.gradle.kts) Convert the Groovy-based sentry.gradle script to Kotlin DSL for better type safety and IDE support. The original sentry.gradle is kept as a one-line shim that forwards to sentry.gradle.kts for backward compatibility with existing app/build.gradle references. Key changes: - Full Kotlin DSL rewrite with proper type annotations - Groovy interop via DefaultGroovyMethods and groovy.lang.Closure wrappers - AGP reflection wrapped in try/catch for resilience - Cross-version Kotlin compat using Java Character API - ProcessBuilder deadlock prevention (read stdout before waitFor) - Handle both File and Directory types for root property - Expo plugin auto-migrates old sentry.gradle refs to sentry.gradle.kts Co-Authored-By: Claude Opus 4.6 --- .github/file-filters.yml | 4 +- .../rn.patch.app.build.gradle.js | 2 +- packages/core/.npmignore | 1 + packages/core/plugin/src/withSentryAndroid.ts | 9 +- packages/core/sentry.gradle | 589 +-------------- packages/core/sentry.gradle.kts | 702 ++++++++++++++++++ .../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 +- 10 files changed, 730 insertions(+), 598 deletions(-) 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/.npmignore b/packages/core/.npmignore index a8241b93f9..bbde34660c 100644 --- a/packages/core/.npmignore +++ b/packages/core/.npmignore @@ -10,6 +10,7 @@ !ts3.8/**/* !RNSentry.podspec !sentry.gradle +!sentry.gradle.kts !react-native.config.js !/ios/**/* !/android/**/* 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 index 9b99dab531..cbda326b80 100644 --- a/packages/core/sentry.gradle +++ b/packages/core/sentry.gradle @@ -1,588 +1 @@ -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 -} +apply from: new File(buildscript.sourceFile.parentFile, "sentry.gradle.kts") diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts new file mode 100644 index 0000000000..5c878a1f9f --- /dev/null +++ b/packages/core/sentry.gradle.kts @@ -0,0 +1,702 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import org.codehaus.groovy.runtime.DefaultGroovyMethods +import org.gradle.api.tasks.Exec +import java.io.FileInputStream +import java.util.Properties +import java.util.regex.Pattern +import javax.inject.Inject + +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 process = + ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/react-native/package.json')")) + .directory(rootDir) + .redirectErrorStream(true) + .start() + val output = + process.inputStream + .bufferedReader() + .readText() + .trim() + process.waitFor() + 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 process = + ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/cli/package.json')")) + .directory(rootDir) + .redirectErrorStream(true) + .start() + val output = + process.inputStream + .bufferedReader() + .readText() + .trim() + process.waitFor() + 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 = DefaultGroovyMethods.getProperties(bundleTask) + 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) { + 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) + } + + if (args.sourcemapOutput != null) { + logger.info("Info: used pre-configured source map files: ${args.sourcemapOutput}") + return ForceSourceMapResult(false, args.bundleOutput, args.sourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) + } + + val forcedSourcemapOutput = File(args.bundleOutput.path + ".map") + val props = + org.codehaus.groovy.runtime.DefaultGroovyMethods + .getProperties(bundleTask) + + @Suppress("UNCHECKED_CAST") + val cmd = (props["commandLine"] as? MutableList) + + @Suppress("UNCHECKED_CAST") + val cmdArgs = (props["args"] as? MutableList) + if (cmd != null && cmdArgs != null) { + cmd.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) + cmdArgs.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) + bundleTask.setProperty("commandLine", cmd) + bundleTask.setProperty("args", cmdArgs) + logger.info("forced sourcemap file output for `${bundleTask.name}` task") + } + + return ForceSourceMapResult(true, args.bundleOutput, forcedSourcemapOutput, 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 = Character.toLowerCase(match[0]).toString() + 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") + + try { + 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 && it.parameterTypes[1].isInterface + } ?: throw NoSuchMethodException("onVariants with 2 parameters (Action interface) not found") + 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 + }, + ) + } catch (e: Exception) { + project.logger.warn( + "[sentry] Failed to set up variant processing via AGP reflection: ${e.message}. " + + "Source maps will not be uploaded. Please report this issue at " + + "https://github.com/getsentry/sentry-react-native/issues", + ) + } +} + +fun processVariant(v: Any) { + val vName = v.javaClass.getMethod("getName").invoke(v) as String + if (vName.contains("debug", ignoreCase = true)) return + + val variantCapitalized = Character.toUpperCase(vName[0]).toString() + vName.substring(1) + 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 = DefaultGroovyMethods.getProperties(bundleTask) + var reactRoot: File? = props["workingDir"] as? File + if (reactRoot == null) { + val rootProvider = props["root"] as? org.gradle.api.provider.Provider<*> + val rootValue = rootProvider?.get() + reactRoot = + when (rootValue) { + is File -> rootValue + is org.gradle.api.file.Directory -> rootValue.asFile + else -> null + } + } + 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) + .redirectErrorStream(true) + .start() + val processOutput = process.inputStream.bufferedReader().readText() + process.waitFor() + project.logger.lifecycle("Check generated source map for Debug ID: $processOutput") + + 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("${layout.buildDirectory.get().asFile}/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 c3aeb3f21c..aef5ed3fa7 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 94b17950b96562a0f4d0a7ee84eaf815f1bc386a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 11:43:34 +0200 Subject: [PATCH 02/10] docs: Add changelog entry for sentry.gradle Kotlin DSL conversion Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d712d13913..c5fffa02e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ - Fix duplicate JS error reporting on iOS New Architecture when the native SDK is initialized early via `sentry.options.json` ("Capture App Start Errors"). It's done by applying the `ExceptionsManager.reportException` C++ wrapper filter in both init paths ([#6145](https://github.com/getsentry/sentry-react-native/pull/6145)) - Fix boolean options from `sentry.options.json` being ignored on Android when using `RNSentrySDK.init` ([#6130](https://github.com/getsentry/sentry-react-native/pull/6130)) +### Internal + +- Convert `sentry.gradle` to Kotlin DSL (`sentry.gradle.kts`) ([#6119](https://github.com/getsentry/sentry-react-native/pull/6119)) + - The old `sentry.gradle` is kept as a shim forwarding to the new `.kts` file for backward compatibility + ### Dependencies - Bump JavaScript SDK from v10.51.0 to v10.53.1 ([#6108](https://github.com/getsentry/sentry-react-native/pull/6108), [#6139](https://github.com/getsentry/sentry-react-native/pull/6139)) From eb886bd55eb380b5b03bc72789260f95b6a76123 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 12:30:10 +0200 Subject: [PATCH 03/10] fix(android): Don't merge stderr into stdout in path resolution Node stderr output (deprecation warnings etc.) would corrupt the resolved path string when redirectErrorStream(true) was used. Read only stdout and close the error stream instead. Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 5c878a1f9f..5eb002ffa2 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -135,13 +135,13 @@ fun resolveSentryReactNativeSDKPath(reactRoot: File): String { val process = ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/react-native/package.json')")) .directory(rootDir) - .redirectErrorStream(true) .start() val output = process.inputStream .bufferedReader() .readText() .trim() + process.errorStream.close() process.waitFor() resolvedSentryPath = File(output).parentFile } catch (_: Throwable) { @@ -159,13 +159,13 @@ fun resolveSentryCliPackagePath(reactRoot: File): String { val process = ProcessBuilder(listOf("node", "--print", "require.resolve('@sentry/cli/package.json')")) .directory(rootDir) - .redirectErrorStream(true) .start() val output = process.inputStream .bufferedReader() .readText() .trim() + process.errorStream.close() process.waitFor() resolvedCliPath = File(output).parentFile } catch (_: Throwable) { From cd88dc915409fd9392a7149c3a07be3f95e2bd27 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 12:32:23 +0200 Subject: [PATCH 04/10] Update packages/core/sentry.gradle.kts Co-authored-by: sentry-warden[bot] <258096371+sentry-warden[bot]@users.noreply.github.com> --- packages/core/sentry.gradle.kts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 5eb002ffa2..1d3b7d47b4 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -199,10 +199,10 @@ fun extractBundleTaskArguments( (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 bundleCommand = (props["bundleCommand"] as? org.gradle.api.provider.Provider<*>)?.orNull as? String + val jsBundleDir = (props["jsBundleDir"] as? org.gradle.api.provider.Provider<*>)?.orNull + val jsSourceMapsDir = (props["jsSourceMapsDir"] as? org.gradle.api.provider.Provider<*>)?.orNull + val jsIntermediateSourceMapsDir = (props["jsIntermediateSourceMapsDir"] as? org.gradle.api.provider.Provider<*>)?.orNull val bundleDirFile = when (jsBundleDir) { From 50f18b34cce36d472cca8c2a58ae2264c759b105 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 12:43:38 +0200 Subject: [PATCH 05/10] fix(android): Use toString() for config values to handle Groovy GStrings Groovy string interpolation produces GStringImpl objects, not java.lang.String. The as? String cast silently returns null for GStrings, causing user-configured values (scripts, properties, etc.) to be ignored. Using ?.toString() handles both types correctly. Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 1d3b7d47b4..f6d9b1912d 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -445,11 +445,13 @@ fun processVariant(v: Any) { val sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) val copyDebugIdScript = - (config["copyDebugIdScript"] as? String) + config["copyDebugIdScript"] + ?.toString() ?.let { file(it).absolutePath } ?: "$sentryPackage/scripts/copy-debugid.js" val hasSourceMapDebugIdScript = - (config["hasSourceMapDebugIdScript"] as? String) + config["hasSourceMapDebugIdScript"] + ?.toString() ?.let { file(it).absolutePath } ?: "$sentryPackage/scripts/has-sourcemap-debugid.js" @@ -481,7 +483,7 @@ fun processVariant(v: Any) { workingDir(reactRoot) var propertiesFile = - (config["sentryProperties"] as? String) + config["sentryProperties"]?.toString() ?: "$reactRoot/android/sentry.properties" val flavorAware = config["flavorAware"] == true @@ -532,7 +534,7 @@ fun processVariant(v: Any) { val args = mutableListOf(cliExecutable) - val logLevel = config["logLevel"] as? String + val logLevel = config["logLevel"]?.toString() if (logLevel != null) { args.addAll(listOf("--log-level", logLevel)) } @@ -586,7 +588,8 @@ fun processVariant(v: Any) { val sentryPackage = resolveSentryReactNativeSDKPath(reactRoot) val collectModulesScript = - (config["collectModulesScript"] as? String) + config["collectModulesScript"] + ?.toString() ?.let { file(it).absolutePath } ?: "$sentryPackage/dist/js/tools/collectModules.js" From 4b8bec2f1b0b48fef351b7d87b8f8ef019503b7a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 13:06:44 +0200 Subject: [PATCH 06/10] fix(android): Handle GString type for sentryProperties config value The sentryProperties config value can be a Groovy GString when set with string interpolation. Use toString() fallback instead of strict is String check to handle both types. Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index f6d9b1912d..80ddacb278 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -682,9 +682,9 @@ project.afterEvaluate { val sentryPropertiesFile = when (sentryProperties) { - is String -> file(sentryProperties) is File -> sentryProperties - else -> null + null -> null + else -> file(sentryProperties.toString()) } if (sentryPropertiesFile != null) { From e1325fc4bfa4999bfcabf4a0e99a0c7aa8aa9570 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 13:32:04 +0200 Subject: [PATCH 07/10] fix(android): Fail gracefully when sourcemap injection can't access task properties Return null result instead of shouldCleanUp=true when cmd/cmdArgs properties are unavailable, preventing upload attempts against a non-existent sourcemap file. Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 80ddacb278..e3bd951b13 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -261,14 +261,17 @@ fun forceSourceMapOutputFromBundleTask(bundleTask: Task): ForceSourceMapResult { @Suppress("UNCHECKED_CAST") val cmdArgs = (props["args"] as? MutableList) - if (cmd != null && cmdArgs != null) { - cmd.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) - cmdArgs.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) - bundleTask.setProperty("commandLine", cmd) - bundleTask.setProperty("args", cmdArgs) - logger.info("forced sourcemap file output for `${bundleTask.name}` task") + if (cmd == null || cmdArgs == null) { + logger.warn("[sentry] Could not inject --sourcemap-output for '${bundleTask.name}'. Source maps will not be uploaded.") + return ForceSourceMapResult(false, null, null, null, null) } + cmd.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) + cmdArgs.addAll(listOf("--sourcemap-output", forcedSourcemapOutput.path)) + bundleTask.setProperty("commandLine", cmd) + bundleTask.setProperty("args", cmdArgs) + logger.info("forced sourcemap file output for `${bundleTask.name}` task") + return ForceSourceMapResult(true, args.bundleOutput, forcedSourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) } From c3effa643177ba58e500b41e99c3fb1a47184cdb Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 13:55:12 +0200 Subject: [PATCH 08/10] fix(core): Prevent migration regex from rewriting io.sentry.android.gradle plugin The lookbehind ensures the regex only matches sentry.gradle preceded by a quote or slash character, avoiding false positives on package names. Co-Authored-By: Claude Opus 4.6 --- packages/core/plugin/src/withSentryAndroid.ts | 5 +++-- .../test/expo-plugin/modifyAppBuildGradle.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index 850a5b1476..586693f934 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -45,8 +45,9 @@ export function modifyAppBuildGradle(buildGradle: string): string { } // 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 a regex to avoid matching package names like io.sentry.android.gradle + if (/(?<=[/"'])sentry\.gradle(?!\.kts)/.test(buildGradle)) { + return buildGradle.replace(/(?<=[/"'])sentry\.gradle(?!\.kts)/g, 'sentry.gradle.kts'); } // Use the same location that sentry-wizard uses diff --git a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts index c152090dbc..36f4b56687 100644 --- a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts +++ b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts @@ -34,6 +34,13 @@ android { } `; +const buildGradleWithAndroidGradlePlugin = ` +apply plugin: "io.sentry.android.gradle" + +android { +} +`; + const buildGradleWithOutReactGradleScript = ` `; @@ -58,6 +65,13 @@ describe('Configures Android native project correctly', () => { expect(modifyAppBuildGradle(buildGradleWithOldSentryGradle)).toStrictEqual(buildGradleWithSentry); }); + it('Does not rewrite io.sentry.android.gradle plugin declaration', () => { + const result = modifyAppBuildGradle(buildGradleWithAndroidGradlePlugin); + expect(result).toContain('io.sentry.android.gradle"'); + expect(result).not.toContain('io.sentry.android.gradle.kts'); + expect(result).toContain('sentry.gradle.kts'); + }); + it('Warns to file a bug report if no react.gradle is found', () => { modifyAppBuildGradle(buildGradleWithOutReactGradleScript); expect(warnOnce).toHaveBeenCalled(); From 3d467373ee2603ceb9f312cf1e15614f64a7f561 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 14:30:25 +0200 Subject: [PATCH 09/10] fix(android): Restore legacy fallback for RN < 0.71 and fix reactRoot smart-cast - Re-add extractBundleTaskArgumentsLegacy for projects on RN 0.65-0.70 that lack Provider-based bundle task properties - Assign reactRoot to immutable val after null check for clean Kotlin smart-casting in task lambdas Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 69 ++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index e3bd951b13..1b93ed352c 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -238,8 +238,66 @@ data class ForceSourceMapResult( val bundleCommand: String?, ) +fun extractBundleTaskArgumentsLegacy( + cmdArgs: List, + project: Project, +): BundleTaskArgs { + var bundleOutput: String? = null + var sourcemapOutput: String? = null + var packagerSourcemapOutput: String? = null + + cmdArgs.forEachIndexed { i, arg -> + if (arg == "--bundle-output" && i + 1 < cmdArgs.size) { + bundleOutput = cmdArgs[i + 1] + project.logger.info("--bundle-output: `$bundleOutput`") + } else if (arg == "--sourcemap-output" && i + 1 < cmdArgs.size) { + sourcemapOutput = cmdArgs[i + 1] + packagerSourcemapOutput = sourcemapOutput + project.logger.info("--sourcemap-output param: `$sourcemapOutput`") + } + } + + @Suppress("UNCHECKED_CAST") + val reactExt = + try { + project.extensions.extraProperties.get("react") as? Map + } catch (_: Throwable) { + null + } + + val enableHermes = reactExt?.get("enableHermes") == true + project.logger.info("enableHermes: `$enableHermes`") + + if (bundleOutput != null && sourcemapOutput != null && enableHermes) { + val pattern = Pattern.compile("(/|\\\\)intermediates\\1sourcemaps\\1react\\1") + val matcher = pattern.matcher(sourcemapOutput!!) + if (matcher.find()) { + project.logger.info("sourcemapOutput has the wrong path, let's fix it.") + sourcemapOutput = bundleOutput!! + .replace(Regex("(/|\\\\)generated\\1assets\\1react\\1"), "\$1generated\$1sourcemaps\$1react\$1") + ".map" + project.logger.info("sourcemapOutput new path: `$sourcemapOutput`") + } + } + + val bundleCommand = reactExt?.get("bundleCommand") as? String ?: "bundle" + + return BundleTaskArgs( + bundleOutput?.let { File(it) }, + sourcemapOutput?.let { File(it) }, + packagerSourcemapOutput?.let { File(it) }, + bundleCommand, + ) +} + fun forceSourceMapOutputFromBundleTask(bundleTask: Task): ForceSourceMapResult { - val args = extractBundleTaskArguments(bundleTask, logger) + var args = extractBundleTaskArguments(bundleTask, logger) + + if (args.bundleOutput == null) { + val props = DefaultGroovyMethods.getProperties(bundleTask) + @Suppress("UNCHECKED_CAST") + val cmdArgs = (props["args"] as? List) ?: emptyList() + args = extractBundleTaskArgumentsLegacy(cmdArgs, project) + } if (args.bundleOutput == null) { logger.warn("[sentry] Could not extract bundle task arguments for '${bundleTask.name}'. Source maps will not be uploaded.") @@ -403,21 +461,22 @@ fun processVariant(v: Any) { val packagerSourcemapOutput = result.packagerSourcemapOutput val props = DefaultGroovyMethods.getProperties(bundleTask) - var reactRoot: File? = props["workingDir"] as? File - if (reactRoot == null) { + var reactRootResolved: File? = props["workingDir"] as? File + if (reactRootResolved == null) { val rootProvider = props["root"] as? org.gradle.api.provider.Provider<*> val rootValue = rootProvider?.get() - reactRoot = + reactRootResolved = when (rootValue) { is File -> rootValue is org.gradle.api.file.Directory -> rootValue.asFile else -> null } } - if (reactRoot == null) { + if (reactRootResolved == null) { project.logger.warn("[sentry] Could not determine reactRoot for '${bundleTask.name}'.") return } + val reactRoot = reactRootResolved val modulesOutput = "$reactRoot/android/app/src/main/assets/modules.json" From 0fcd7e0898213ff2c2d756d83080454c16dc4a3f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 15 May 2026 14:37:36 +0200 Subject: [PATCH 10/10] fix(android): Fix smart-cast error on Gradle 7.5.1 for var args Extract args.bundleOutput into local val to avoid smart-cast failure on older Kotlin compilers (Gradle 7.5.1 / RN 0.71). Co-Authored-By: Claude Opus 4.6 --- packages/core/sentry.gradle.kts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/sentry.gradle.kts b/packages/core/sentry.gradle.kts index 1b93ed352c..4b6dc4e114 100644 --- a/packages/core/sentry.gradle.kts +++ b/packages/core/sentry.gradle.kts @@ -294,22 +294,24 @@ fun forceSourceMapOutputFromBundleTask(bundleTask: Task): ForceSourceMapResult { if (args.bundleOutput == null) { val props = DefaultGroovyMethods.getProperties(bundleTask) + @Suppress("UNCHECKED_CAST") val cmdArgs = (props["args"] as? List) ?: emptyList() args = extractBundleTaskArgumentsLegacy(cmdArgs, project) } - if (args.bundleOutput == null) { + val bundleOutput = args.bundleOutput + if (bundleOutput == 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) } if (args.sourcemapOutput != null) { logger.info("Info: used pre-configured source map files: ${args.sourcemapOutput}") - return ForceSourceMapResult(false, args.bundleOutput, args.sourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) + return ForceSourceMapResult(false, bundleOutput, args.sourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) } - val forcedSourcemapOutput = File(args.bundleOutput.path + ".map") + val forcedSourcemapOutput = File(bundleOutput.path + ".map") val props = org.codehaus.groovy.runtime.DefaultGroovyMethods .getProperties(bundleTask) @@ -330,7 +332,7 @@ fun forceSourceMapOutputFromBundleTask(bundleTask: Task): ForceSourceMapResult { bundleTask.setProperty("args", cmdArgs) logger.info("forced sourcemap file output for `${bundleTask.name}` task") - return ForceSourceMapResult(true, args.bundleOutput, forcedSourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) + return ForceSourceMapResult(true, bundleOutput, forcedSourcemapOutput, args.packagerSourcemapOutput, args.bundleCommand) } data class VariantInfo(