diff --git a/packages/react-native/android/upload-sourcemaps.gradle b/packages/react-native/android/upload-sourcemaps.gradle index 659584dd..1ffc01b5 100644 --- a/packages/react-native/android/upload-sourcemaps.gradle +++ b/packages/react-native/android/upload-sourcemaps.gradle @@ -1,48 +1,205 @@ -import groovy.json.JsonBuilder +import groovy.json.JsonOutput import groovy.json.JsonSlurper +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import javax.inject.Inject -tasks.register("uploadSourceMapsToBacktrace") { - group = "backtrace" - description = "This task uploads source maps generated by the react-native builder to Backtrace based on the .backtracejsrc file." +abstract class BacktraceSourceMapUploadTask extends DefaultTask { + @Input + abstract Property getVariantName() - doLast { - // Fetch the build variant (debug or release) - // Default to 'release' if not set - def buildVariant = project.hasProperty("buildVariant") ? project.buildVariant.toLowerCase() : "release" + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getSourceMap() - def debugIdPathEnv = System.getenv("DEBUG_ID_PATH") + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getDebugIdFile() - def debugIdFile = debugIdPathEnv ? file(debugIdPathEnv) : layout.buildDirectory - .dir("intermediates/sourcemaps/react/${buildVariant}/.backtrace-sourcemap-id") - .get() - .asFile + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getConfigFile() - if (!debugIdFile.exists()) { - println("Backtrace: Cannot find .backtrace-sourcemap-id file. Check if customSerializer has been set to Backtrace serializer in metro.config.js.") + @Internal + abstract DirectoryProperty getAppRoot() + + @OutputFile + abstract RegularFileProperty getProcessedSourceMap() + + @Inject + abstract ExecOperations getExecOperations() + + BacktraceSourceMapUploadTask() { + outputs.upToDateWhen { task -> task.processedSourceMap.get().asFile.isFile() } + } + + @TaskAction + void upload() { + def processed = processedSourceMap.get().asFile + processed.delete() + + def variant = variantName.get() + def map = sourceMap.singleFile + def idFile = debugIdFile.singleFile + def config = configFile.singleFile + def debugId = idFile.isFile() ? idFile.getText("UTF-8").trim() : "" + + if (!map.isFile()) { + logger.warn("Backtrace: Cannot find the ${variant} source map at ${map}. Hermes builds need -output-source-map in hermesFlags. Skipping the upload.") + return + } + if (!debugId) { + logger.warn("Backtrace: The ${variant} bundle has no debug id. Check that customSerializer is set to the Backtrace serializer in metro.config.js. Skipping the upload.") + return + } + if (!config.isFile()) { + logger.warn("Backtrace: Cannot find ${config}. Skipping the ${variant} source map upload.") return } - def sourcemapDestinationDirectory = layout.buildDirectory.dir("generated/sourcemaps/react").get().asFile - def mapFiles = fileTree(dir: sourcemapDestinationDirectory, include: '**/*.map').files - def mapFile = mapFiles ? mapFiles.iterator().next() : null + def mapData = new JsonSlurper().parse(map, "UTF-8") + mapData.debugId = debugId + processed.parentFile.mkdirs() + processed.setText(JsonOutput.toJson(mapData), "UTF-8") - if (!mapFile) { - println("Backtrace: Cannot find source map file.") - return + def command = ["npx", "--yes", "@backtrace/javascript-cli", "upload", "-p", processed.absolutePath] + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + command = ["cmd", "/c"] + command } + def result = execOperations.exec { spec -> + spec.workingDir(appRoot.get().asFile) + spec.commandLine(command) + spec.ignoreExitValue = true + } + if (result.exitValue != 0) { + throw new GradleException("Backtrace: The ${variant} source map upload failed with exit code ${result.exitValue}. Pass -PbacktraceUploadSourceMaps=false to build without uploading.") + } + } +} + +abstract class BacktraceDebugIdPathLock implements BuildService { +} - def jsonSlurper = new JsonSlurper() - def mapData = jsonSlurper.parse(mapFile) - mapData.debugId = debugIdFile.text - def jsonBuilder = new JsonBuilder(mapData) - mapFile.text = jsonBuilder.toString() +def legacyTaskName = "uploadSourceMapsToBacktrace" + +if (tasks.names.contains(legacyTaskName)) { + return +} + +def mentionsLegacyTask = { Collection taskPaths -> taskPaths.any { it.substring(it.lastIndexOf(":") + 1) == legacyTaskName } } +def legacyRequested = mentionsLegacyTask(gradle.startParameter.taskNames) +def uploadsEnabled = !mentionsLegacyTask(gradle.startParameter.excludedTaskNames) && + !"false".equalsIgnoreCase(providers.gradleProperty("backtraceUploadSourceMaps").getOrElse("true")) +def legacyVariant = providers.gradleProperty("buildVariant").getOrElse("release") +def debugIdPath = providers.environmentVariable("DEBUG_ID_PATH").orNull +// Under the configuration cache, tasks of one project run in parallel, and every bundle writes the one DEBUG_ID_PATH file. +def debugIdPathLock = debugIdPath ? gradle.sharedServices.registerIfAbsent("backtraceDebugIdPath", BacktraceDebugIdPathLock) { it.maxParallelUsages.set(1) } : null +def sourceMapUploads = [:] +def uploadsByPackagingTask = [:] + +def capitalize = { String name -> Character.toUpperCase(name.charAt(0)).toString() + name.substring(1) } +def sourceMapOf = { bundleTask -> new File(bundleTask.jsSourceMapsDir.get().asFile, bundleTask.bundleAssetName.get() + ".map") } +def debugIdFileOf = { bundleTask -> + if (debugIdPath) { + return bundleTask.root.file(debugIdPath).get().asFile + } + def packagerMapDir = bundleTask.hermesEnabled.get() ? bundleTask.jsIntermediateSourceMapsDir : bundleTask.jsSourceMapsDir + return new File(packagerMapDir.get().asFile, ".backtrace-sourcemap-id") +} + +def isDebuggable = { variant -> + if (variant.hasProperty("debuggable")) { + return variant.debuggable + } + // Before AGP 8.5, and in Android Studio's analytics wrapper, only the build type exposes debuggable. + return extensions.getByName("android").buildTypes.getByName(variant.buildType).debuggable +} + +def logDebuggableSkip = { String variantName, TaskProvider bundle -> + bundle.configure { + it.doLast { task -> task.logger.lifecycle("Backtrace: ${variantName} is debuggable, skipping its source map upload.") } + } +} + +def registerSourceMapUpload = { String variantName, TaskProvider bundle -> + def capitalized = capitalize(variantName) + def outputDirectory = "intermediates/backtrace/sourcemaps/${variantName}" + def debugIdSnapshot = layout.buildDirectory.file("${outputDirectory}/.backtrace-sourcemap-id") + + bundle.configure { bundleTask -> + def sourceMap = sourceMapOf(bundleTask) + def debugIdFile = debugIdFileOf(bundleTask) + def snapshot = debugIdSnapshot.get().asFile + bundleTask.outputs.file(snapshot) + bundleTask.doFirst { [sourceMap, debugIdFile, snapshot]*.delete() } + bundleTask.doLast { + if (debugIdFile.isFile()) { + snapshot.parentFile.mkdirs() + snapshot.bytes = debugIdFile.bytes + } + } + } + + def upload = tasks.register(legacyTaskName + capitalized, BacktraceSourceMapUploadTask) { task -> + def bundleTask = bundle.get() + task.group = "backtrace" + task.description = "Uploads the ${variantName} source map to Backtrace." + task.dependsOn(bundle) + task.variantName.set(variantName) + task.sourceMap.from(sourceMapOf(bundleTask)) + task.debugIdFile.from(debugIdSnapshot) + task.configFile.from(bundleTask.root.file(".backtracejsrc")) + task.appRoot.set(bundleTask.root) + task.processedSourceMap.set(layout.buildDirectory.file("${outputDirectory}/${bundleTask.bundleAssetName.get()}.map")) + } + sourceMapUploads[variantName] = upload + + if (uploadsEnabled) { + ["package" + capitalized, "package" + capitalized + "Bundle", "sign" + capitalized + "Bundle"].each { uploadsByPackagingTask[it] = upload } + } +} + +tasks.configureEach { task -> + def upload = uploadsByPackagingTask[task.name] + if (upload) { + task.finalizedBy(upload) + } +} + +tasks.register(legacyTaskName) { task -> + task.group = "backtrace" + task.description = "Uploads the -PbuildVariant source map (default: release) to Backtrace. Release builds upload on their own." + // Old documented hooks finalize debug builds with this task; it uploads only when run by name. + if (legacyRequested) { + task.dependsOn({ + def upload = sourceMapUploads.find { it.key.equalsIgnoreCase(legacyVariant) }?.value + if (!upload) { + def available = sourceMapUploads.keySet().join(", ") ?: "none" + task.logger.warn("Backtrace: No release variant named '${legacyVariant}' has a React Native bundle to upload. Release variants: ${available}.") + } + return upload ?: [] + }) + } +} - // Execute Backtrace JS CLI to process upload processed source maps - def command = ["npx", "--yes", "@backtrace/javascript-cli", "upload", "-p", mapFile.absolutePath] - exec { - workingDir file("$rootProject.projectDir/..") - commandLine command +plugins.withId("com.facebook.react") { + plugins.withId("com.android.application") { + def androidComponents = extensions.getByName("androidComponents") + androidComponents.onVariants(androidComponents.selector().all()) { variant -> + def bundleTaskName = "createBundle" + capitalize(variant.name) + "JsAndAssets" + if (!tasks.names.contains(bundleTaskName)) { + return + } + def bundle = tasks.named(bundleTaskName) + if (debugIdPathLock) { + bundle.configure { it.usesService(debugIdPathLock) } + } + if (isDebuggable(variant)) { + logDebuggableSkip(variant.name, bundle) + } else { + registerSourceMapUpload(variant.name, bundle) + } } } }