diff --git a/CHANGELOG.md b/CHANGELOG.md index c36cce1f..cb8b49e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Fix FileAlreadyExistsException errors when building ReactNative projects with Hermes [#482](https://github.com/bugsnag/bugsnag-android-gradle-plugin/pull/482) +* Support using objcopy instead of objdump to extract native symbols (when supported by the current NDK). + [#484](https://github.com/bugsnag/bugsnag-android-gradle-plugin/pull/484) ## 7.3.1 (2022-10-05) diff --git a/detekt-baseline.xml b/detekt-baseline.xml index 44c65c55..6b6325ec 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -4,22 +4,24 @@ ComplexCondition:AndroidManifestParser.kt$AndroidManifestParser$apiKey == null || "" == apiKey || versionCode == null || buildUuid == null || versionName == null || applicationId == null ComplexCondition:BugsnagPlugin.kt$BugsnagPlugin$!jvmMinificationEnabled && !ndkEnabled && !unityEnabled && !reactNativeEnabled - LongParameterList:BugsnagGenerateNdkSoMappingTask.kt$BugsnagGenerateNdkSoMappingTask.Companion$( project: Project, variant: BaseVariant, output: ApkVariantOutput, objdumpPaths: Provider<Map<String, String>>, searchPaths: List<File>, soMappingOutputPath: String ) + LongParameterList:BugsnagGenerateNdkSoMappingTask.kt$BugsnagGenerateNdkSoMappingTask.Companion$( project: Project, variant: BaseVariant, output: ApkVariantOutput, ndk: NdkToolchain, searchPaths: List<File>, soMappingOutputPath: String ) + LongParameterList:BugsnagUploadSoSymTask.kt$BugsnagUploadSoSymTask.Companion$( project: Project, variant: BaseVariantOutput, ndkToolchain: NdkToolchain, uploadType: UploadType, generateTaskProvider: TaskProvider<out AbstractSoMappingTask>, httpClientHelperProvider: Provider<out BugsnagHttpClientHelper>, ndkUploadClientProvider: Provider<out UploadRequestClient>, ) MagicNumber:BugsnagPluginExtension.kt$BugsnagPluginExtension$60000 MagicNumber:BugsnagReleasesTask.kt$BugsnagReleasesTask$200 MagicNumber:MappingFileProvider.kt$9 - ReturnCount:BugsnagPlugin.kt$BugsnagPlugin$ @Suppress("SENSELESS_COMPARISON") internal fun isUnityLibraryUploadEnabled( bugsnag: BugsnagPluginExtension, android: BaseExtension ): Boolean + MaxLineLength:NdkToolchain.kt$NdkToolchain.Companion$/* * SdkComponents.ndkDirectory * https://developer.android.com/reference/tools/gradle-api/7.2/com/android/build/api/dsl/SdkComponents#ndkDirectory() * sometimes fails to resolve when ndkPath is not defined (Cannot query the value of this property because it has * no value available.). This means that even `map` and `isPresent` will break. * * So we also fall back use the old BaseExtension if it appears broken */ + ReturnCount:BugsnagGenerateUnitySoMappingTask.kt$BugsnagGenerateUnitySoMappingTask.Companion$ @Suppress("SENSELESS_COMPARISON") internal fun isUnityLibraryUploadEnabled( bugsnag: BugsnagPluginExtension, android: BaseExtension ): Boolean ReturnCount:BugsnagPlugin.kt$BugsnagPlugin$ private fun registerUploadSourceMapTask( project: Project, variant: BaseVariant, output: BaseVariantOutput, bugsnag: BugsnagPluginExtension, manifestInfoProvider: Provider<RegularFile> ): TaskProvider<out BugsnagUploadJsSourceMapTask>? ReturnCount:ManifestUuidTaskV2Compat.kt$internal fun createManifestUpdateTask( bugsnag: BugsnagPluginExtension, project: Project, variantName: String, variantOutput: VariantOutput ): TaskProvider<BugsnagManifestUuidTask>? - ReturnCount:SharedObjectMappingFileFactory.kt$SharedObjectMappingFileFactory$ fun generateSoMappingFile(project: Project, params: Params): File? SpreadOperator:DexguardCompat.kt$(buildDir, *path, variant.dirName, outputDir, "mapping.txt") + SwallowedException:NdkToolchain.kt$NdkToolchain.Companion$catch (e: Exception) { null } + SwallowedException:NdkToolchain.kt$NdkToolchain.Companion$catch (e: Exception) { return@provider extensions.getByType(BaseExtension::class.java).ndkDirectory.absoluteFile } + TooGenericExceptionCaught:AbstractSoMappingTask.kt$AbstractSoMappingTask$e: Exception TooGenericExceptionCaught:BugsnagHttpClientHelper.kt$exc: Throwable TooGenericExceptionCaught:BugsnagMultiPartUploadRequest.kt$BugsnagMultiPartUploadRequest$exc: Throwable TooGenericExceptionCaught:BugsnagReleasesTask.kt$BugsnagReleasesTask$exc: Throwable TooGenericExceptionCaught:MappingFileProvider.kt$exc: Throwable - TooGenericExceptionCaught:SharedObjectMappingFileFactory.kt$SharedObjectMappingFileFactory$e: Exception - TooGenericExceptionCaught:SharedObjectMappingFileFactory.kt$SharedObjectMappingFileFactory$ex: Throwable + TooGenericExceptionCaught:NdkToolchain.kt$NdkToolchain.Companion$e: Exception TooManyFunctions:BugsnagPlugin.kt$BugsnagPlugin : Plugin - UnusedPrivateMember:BugsnagPlugin.kt$BugsnagPlugin$private fun BaseVariantOutput.findVersionCode(): Int diff --git a/features/fixtures/config/ndk/objcopy.gradle b/features/fixtures/config/ndk/objcopy.gradle new file mode 100644 index 00000000..e62bfeca --- /dev/null +++ b/features/fixtures/config/ndk/objcopy.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'com.bugsnag:bugsnag-android:5.26.0' +} + +android.ndkVersion = "23.0.7599858" +bugsnag.useLegacyNdkSymbolUpload = false diff --git a/features/fixtures/config/ndk/old_sdk_upload_failure.gradle b/features/fixtures/config/ndk/old_sdk_upload_failure.gradle new file mode 100644 index 00000000..55dee428 --- /dev/null +++ b/features/fixtures/config/ndk/old_sdk_upload_failure.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'com.bugsnag:bugsnag-android:5.9.4' +} + +android.ndkVersion = "23.0.7599858" +bugsnag.useLegacyNdkSymbolUpload = true diff --git a/features/fixtures/config/ndk/standard.gradle b/features/fixtures/config/ndk/standard.gradle new file mode 100644 index 00000000..10049421 --- /dev/null +++ b/features/fixtures/config/ndk/standard.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation 'com.bugsnag:bugsnag-android:5.9.4' +} diff --git a/features/fixtures/ndkapp/app/build.gradle b/features/fixtures/ndkapp/app/build.gradle index 6df04b51..38be7a6b 100644 --- a/features/fixtures/ndkapp/app/build.gradle +++ b/features/fixtures/ndkapp/app/build.gradle @@ -66,10 +66,6 @@ android { } } -dependencies { - implementation 'com.bugsnag:bugsnag-android:5.9.4' -} - bugsnag { uploadNdkMappings = true endpoint = "http://localhost:9339/builds" @@ -95,3 +91,6 @@ bugsnag { ] } } + +def ndkConfig = System.env.BUGSNAG_NDK_CONFIG ?: "standard" +apply from: "../../config/ndk/${ndkConfig}.gradle" diff --git a/features/fixtures/rn-monorepo/abc/android/app/src/main/AndroidManifest.xml b/features/fixtures/rn-monorepo/abc/android/app/src/main/AndroidManifest.xml index ad008b47..191aba6e 100644 --- a/features/fixtures/rn-monorepo/abc/android/app/src/main/AndroidManifest.xml +++ b/features/fixtures/rn-monorepo/abc/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ diff --git a/features/fixtures/rn-monorepo/abc/android/build.gradle b/features/fixtures/rn-monorepo/abc/android/build.gradle index 87ebf1fc..9b8c8841 100644 --- a/features/fixtures/rn-monorepo/abc/android/build.gradle +++ b/features/fixtures/rn-monorepo/abc/android/build.gradle @@ -4,8 +4,8 @@ buildscript { ext { buildToolsVersion = "30.0.2" minSdkVersion = 21 - compileSdkVersion = 30 - targetSdkVersion = 30 + compileSdkVersion = 31 + targetSdkVersion = 31 } repositories { mavenCentral() @@ -26,9 +26,20 @@ allprojects { repositories { mavenCentral() mavenLocal() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url("$rootDir/../../node_modules/react-native/android") + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + exclusiveContent { + // We get React Native's Android binaries exclusively through npm, + // from a local Maven repo inside node_modules/react-native/. + // (The use of exclusiveContent prevents looking elsewhere like Maven Central + // and potentially getting a wrong version.) + filter { + includeGroup "com.facebook.react" + } + forRepository { + maven { + url("$rootDir/../../node_modules/react-native/android") + } + } } maven { // Android JSC is installed from npm diff --git a/features/fixtures/rn-monorepo/xyz/android/app/src/main/AndroidManifest.xml b/features/fixtures/rn-monorepo/xyz/android/app/src/main/AndroidManifest.xml index 93dd554d..e5647203 100644 --- a/features/fixtures/rn-monorepo/xyz/android/app/src/main/AndroidManifest.xml +++ b/features/fixtures/rn-monorepo/xyz/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ diff --git a/features/fixtures/rn-monorepo/xyz/android/build.gradle b/features/fixtures/rn-monorepo/xyz/android/build.gradle index ed5a5684..8f622085 100644 --- a/features/fixtures/rn-monorepo/xyz/android/build.gradle +++ b/features/fixtures/rn-monorepo/xyz/android/build.gradle @@ -4,8 +4,8 @@ buildscript { ext { buildToolsVersion = "29.0.2" minSdkVersion = 16 - compileSdkVersion = 29 - targetSdkVersion = 29 + compileSdkVersion = 31 + targetSdkVersion = 31 } repositories { google() @@ -21,9 +21,20 @@ buildscript { allprojects { repositories { mavenLocal() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url("$rootDir/../node_modules/react-native/android") + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + exclusiveContent { + // We get React Native's Android binaries exclusively through npm, + // from a local Maven repo inside node_modules/react-native/. + // (The use of exclusiveContent prevents looking elsewhere like Maven Central + // and potentially getting a wrong version.) + filter { + includeGroup "com.facebook.react" + } + forRepository { + maven { + url("$rootDir/../node_modules/react-native/android") + } + } } maven { // Android JSC is installed from npm diff --git a/features/fixtures/rn065/android/app/src/main/AndroidManifest.xml b/features/fixtures/rn065/android/app/src/main/AndroidManifest.xml index afcb33f4..78032638 100644 --- a/features/fixtures/rn065/android/app/src/main/AndroidManifest.xml +++ b/features/fixtures/rn065/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ diff --git a/features/fixtures/rn065/android/build.gradle b/features/fixtures/rn065/android/build.gradle index d1afbe73..cda34a95 100644 --- a/features/fixtures/rn065/android/build.gradle +++ b/features/fixtures/rn065/android/build.gradle @@ -4,8 +4,8 @@ buildscript { ext { buildToolsVersion = "30.0.2" minSdkVersion = 21 - compileSdkVersion = 30 - targetSdkVersion = 30 + compileSdkVersion = 31 + targetSdkVersion = 31 ndkVersion = "20.1.5948944" } repositories { @@ -29,9 +29,20 @@ allprojects { repositories { mavenCentral() mavenLocal() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url("$rootDir/../node_modules/react-native/android") + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + exclusiveContent { + // We get React Native's Android binaries exclusively through npm, + // from a local Maven repo inside node_modules/react-native/. + // (The use of exclusiveContent prevents looking elsewhere like Maven Central + // and potentially getting a wrong version.) + filter { + includeGroup "com.facebook.react" + } + forRepository { + maven { + url("$rootDir/../node_modules/react-native/android") + } + } } maven { // Android JSC is installed from npm diff --git a/features/ndk_app.feature b/features/ndk_app.feature index edfaff0d..29a38ce0 100644 --- a/features/ndk_app.feature +++ b/features/ndk_app.feature @@ -110,3 +110,27 @@ Scenario: Mapping files uploaded for custom sharedObjectPaths And 1 requests are valid for the android mapping API and match the following: | appId | | com.bugsnag.android.ndkapp | + +Scenario: Mapping fails when using obcopy and an incompatible SDK + When I build the NDK app using the "old_sdk_upload_failure" config + And I wait for 3 seconds + Then I should receive no requests + +Scenario: objcopy is used to produce symbols when configured + When I build the NDK app using the "objcopy" config + And I wait to receive 6 builds + + Then 1 requests are valid for the build API and match the following: + | appVersionCode | appVersion | buildTool | + | 1 | 1.0 | gradle-android | + + And 4 requests are valid for the android so symbol mapping API and match the following: + | projectRoot | sharedObjectName | + | /\S+/ | libnative-lib.so | + | /\S+/ | libnative-lib.so | + | /\S+/ | libnative-lib.so | + | /\S+/ | libnative-lib.so | + + And 1 requests are valid for the android mapping API and match the following: + | appId | + | com.bugsnag.android.ndkapp | diff --git a/features/steps/gradle_plugin_steps.rb b/features/steps/gradle_plugin_steps.rb index ed6c2b97..5098d0bf 100644 --- a/features/steps/gradle_plugin_steps.rb +++ b/features/steps/gradle_plugin_steps.rb @@ -33,6 +33,13 @@ } end +When("I build the NDK app using the {string} config") do |config| + Maze::Runner.environment['BUGSNAG_NDK_CONFIG'] = config + steps %Q{ + And I run the script "features/scripts/build_ndk_app.sh" synchronously +} +end + When("I set the fixture JVM arguments to {string}") do |jvm_args| steps %Q{ When I set environment variable "CUSTOM_JVM_ARGS" to "#{jvm_args}" @@ -59,7 +66,7 @@ def setup_and_run_script(module_config, bugsnag_config, script_path, variant = n end When("I build the failing {string} on AGP {string} using the {string} bugsnag config") do |module_config, agp_version, bugsnag_config| -steps %Q{ + steps %Q{ When I set environment variable "AGP_VERSION" to "#{agp_version}" And I build the failing "#{module_config}" using the "#{bugsnag_config}" bugsnag config } @@ -105,6 +112,16 @@ def setup_and_run_script(module_config, bugsnag_config, script_path, variant = n end end +Then('{int} requests are valid for the android so symbol mapping API and match the following:') do |request_count, data_table| + requests = get_requests_with_field('build', 'soFile') + assert_equal(request_count, requests.length, 'Wrong number of android .so symbol mapping API requests') + Maze::Assertions::RequestSetAssertions.assert_requests_match requests, data_table + + requests.each do |request| + valid_android_so_symbol_mapping_api?(request[:body]) + end +end + Then('{int} requests are valid for the JS source map API and match the following:') do |request_count, data_table| requests = get_requests_with_field('build', 'sourceMap') assert_equal(request_count, requests.length, 'Wrong number of JS source map API requests') @@ -167,16 +184,32 @@ def valid_build_api?(request_body) def valid_android_mapping_api?(request_body) valid_mapping_api?(request_body) + assert_not_nil(request_body['buildUUID']) assert_not_nil(request_body['proguard']) end def valid_android_ndk_mapping_api?(request_body) valid_mapping_api?(request_body) + assert_not_nil(request_body['buildUUID']) assert_not_nil(request_body['soSymbolFile']) end +def valid_android_so_symbol_mapping_api?(request_body) + valid_mapping_api?(request_body) + assert_not_nil(request_body['soFile']) + + gzipped_part = request_body['soFile'] + archive = Zlib::GzipReader.new(StringIO.new(gzipped_part)) + + # check that decompressed this is a valid ELF file: + # https://en.wikipedia.org/wiki/Executable_and_Linkable_Format#File_header + header = archive.read(4) + assert_equal("\x7f\x45\x4c\x46", header, 'not a valid ELF file') +end + def valid_android_unity_ndk_mapping_api?(request_body) valid_mapping_api?(request_body) + assert_not_nil(request_body['buildUUID']) assert_not_nil(request_body['soSymbolTableFile']) end @@ -184,7 +217,6 @@ def valid_mapping_api?(request_body) assert_equal($api_key, request_body['apiKey']) assert_not_nil(request_body['appId']) assert_not_nil(request_body['versionCode']) - assert_not_nil(request_body['buildUUID']) assert_not_nil(request_body['versionName']) end diff --git a/src/main/kotlin/com/bugsnag/android/gradle/AndroidManifestInfoReceiver.kt b/src/main/kotlin/com/bugsnag/android/gradle/AndroidManifestInfoReceiver.kt index 39b38108..2f1812a2 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/AndroidManifestInfoReceiver.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/AndroidManifestInfoReceiver.kt @@ -1,10 +1,10 @@ package com.bugsnag.android.gradle import org.gradle.api.file.RegularFileProperty -import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile interface AndroidManifestInfoReceiver { - @get:Input + @get:InputFile val manifestInfo: RegularFileProperty } diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagFileUploadTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagFileUploadTask.kt index 04baf550..b5dc687f 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagFileUploadTask.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagFileUploadTask.kt @@ -2,13 +2,26 @@ package com.bugsnag.android.gradle import com.bugsnag.android.gradle.internal.BugsnagHttpClientHelper import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal interface BugsnagFileUploadTask { + @get:Input val failOnUploadError: Property + + @get:Input val overwrite: Property + + @get:Input val endpoint: Property + + @get:Input val retryCount: Property + + @get:Input val timeoutMillis: Property + + @get:Internal val httpClientHelper: Property fun configureWith(bugsnag: BugsnagPluginExtension) { diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateNdkSoMappingTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateNdkSoMappingTask.kt index 4147a8fb..2f3e6360 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateNdkSoMappingTask.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateNdkSoMappingTask.kt @@ -3,28 +3,22 @@ package com.bugsnag.android.gradle import com.android.build.VariantOutput import com.android.build.gradle.api.ApkVariantOutput import com.android.build.gradle.api.BaseVariant +import com.bugsnag.android.gradle.internal.AbstractSoMappingTask import com.bugsnag.android.gradle.internal.ExternalNativeBuildTaskUtil +import com.bugsnag.android.gradle.internal.NdkToolchain import com.bugsnag.android.gradle.internal.VariantTaskCompanion import com.bugsnag.android.gradle.internal.clearDir import com.bugsnag.android.gradle.internal.dependsOn import com.bugsnag.android.gradle.internal.forBuildOutput -import com.bugsnag.android.gradle.internal.mapProperty import com.bugsnag.android.gradle.internal.property import com.bugsnag.android.gradle.internal.register -import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import java.io.File @@ -33,43 +27,32 @@ import javax.inject.Inject /** * Task that generates NDK shared object mapping files for upload to Bugsnag. */ -open class BugsnagGenerateNdkSoMappingTask @Inject constructor( +internal abstract class BugsnagGenerateNdkSoMappingTask @Inject constructor( objects: ObjectFactory -) : DefaultTask(), AndroidManifestInfoReceiver { +) : AbstractSoMappingTask() { init { group = BugsnagPlugin.GROUP_NAME description = "Generates NDK mapping files for upload to Bugsnag" } - @get:InputFile - override val manifestInfo: RegularFileProperty = objects.fileProperty() - @get:Input @get:Optional val abi: Property = objects.property() - @get:OutputDirectory - val intermediateOutputDir: DirectoryProperty = objects.directoryProperty() - - @get:Input - val objDumpPaths: MapProperty = objects.mapProperty() - @get:InputFiles val searchDirectories: ConfigurableFileCollection = objects.fileCollection() @TaskAction fun generateMappingFiles() { logger.lifecycle("Generating NDK mapping files") - val searchDirs = searchDirectories.files.toList() - val files = findSharedObjectMappingFiles(searchDirs) + val files = findSharedObjectMappingFiles() processFiles(files) } - private fun findSharedObjectMappingFiles( - searchDirectories: List - ): Collection { - return searchDirectories.flatMap(this::findSharedObjectFiles) + private fun findSharedObjectMappingFiles(): Collection { + return searchDirectories + .flatMap(this::findSharedObjectFiles) .toSortedSet(compareBy { it.absolutePath }) } @@ -97,18 +80,12 @@ open class BugsnagGenerateNdkSoMappingTask @Inject constructor( private fun processFiles(files: Collection) { logger.info("Bugsnag: Found shared object files for upload: $files") - val outputDir = intermediateOutputDir.get().asFile - outputDir.clearDir() + outputDirectory.get().asFile.clearDir() files.forEach { sharedObjectFile -> val arch = sharedObjectFile.parentFile.name - val params = SharedObjectMappingFileFactory.Params( - sharedObjectFile, - requireNotNull(Abi.findByName(arch)), - objDumpPaths.get(), - outputDir - ) - val outputFile = SharedObjectMappingFileFactory.generateSoMappingFile(project, params) + val abi = requireNotNull(Abi.findByName(arch)) { "unknown abi: $arch" } + val outputFile = generateMappingFile(sharedObjectFile, abi) if (outputFile != null) { logger.info("Bugsnag: Created symbol file for $arch at $outputFile") } @@ -127,14 +104,13 @@ open class BugsnagGenerateNdkSoMappingTask @Inject constructor( project: Project, variant: BaseVariant, output: ApkVariantOutput, - objdumpPaths: Provider>, + ndk: NdkToolchain, searchPaths: List, soMappingOutputPath: String ): TaskProvider { val task = register(project, output) { abi.set(output.getFilter(VariantOutput.FilterType.ABI)) - objDumpPaths.set(objdumpPaths) - manifestInfo.set(BugsnagManifestUuidTask.manifestInfoForOutput(project, output)) + ndkToolchain.set(ndk) val externalNativeBuildTaskUtil = ExternalNativeBuildTaskUtil(project.providers) @@ -142,7 +118,7 @@ open class BugsnagGenerateNdkSoMappingTask @Inject constructor( variant.externalNativeBuildProviders.forEach { provider -> searchDirectories.from(externalNativeBuildTaskUtil.findSearchPaths(provider)) } - intermediateOutputDir.set(project.layout.buildDirectory.dir(soMappingOutputPath)) + outputDirectory.set(project.layout.buildDirectory.dir(soMappingOutputPath)) } task.dependsOn(BugsnagManifestUuidTask.forBuildOutput(project, output)) diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateUnitySoMappingTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateUnitySoMappingTask.kt index 3de5ff88..e9e9722b 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateUnitySoMappingTask.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagGenerateUnitySoMappingTask.kt @@ -1,26 +1,20 @@ package com.bugsnag.android.gradle +import com.android.build.gradle.BaseExtension import com.android.build.gradle.api.ApkVariantOutput +import com.bugsnag.android.gradle.internal.AbstractSoMappingTask +import com.bugsnag.android.gradle.internal.NdkToolchain import com.bugsnag.android.gradle.internal.VariantTaskCompanion import com.bugsnag.android.gradle.internal.clearDir -import com.bugsnag.android.gradle.internal.dependsOn -import com.bugsnag.android.gradle.internal.forBuildOutput import com.bugsnag.android.gradle.internal.includesAbi -import com.bugsnag.android.gradle.internal.mapProperty import com.bugsnag.android.gradle.internal.register import okio.BufferedSource import okio.buffer import okio.sink import okio.source -import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction @@ -31,27 +25,18 @@ import javax.inject.Inject /** * Task that generates Unity shared object mapping files for upload to Bugsnag. */ -internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( +internal abstract class BugsnagGenerateUnitySoMappingTask @Inject constructor( objects: ObjectFactory -) : DefaultTask(), AndroidManifestInfoReceiver { +) : AbstractSoMappingTask() { init { group = BugsnagPlugin.GROUP_NAME description = "Generates Unity mapping files for upload to Bugsnag" } - @get:InputFile - override val manifestInfo: RegularFileProperty = objects.fileProperty() - @get:Internal internal lateinit var variantOutput: ApkVariantOutput - @get:Input - val objDumpPaths: MapProperty = objects.mapProperty() - - @get:OutputDirectory - val intermediateOutputDir: DirectoryProperty = objects.directoryProperty() - @get:OutputDirectory val unitySharedObjectDir: DirectoryProperty = objects.directoryProperty() @@ -64,7 +49,7 @@ internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( // search the internal Gradle build + exported Gradle build locations val symbolArchives = getUnitySymbolArchives(rootProjectDir) val copyDir = unitySharedObjectDir.asFile.get() - val outputDir = intermediateOutputDir.asFile.get() + val outputDir = outputDirectory.asFile.get() copyDir.clearDir() outputDir.clearDir() @@ -82,20 +67,30 @@ internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( return } - sharedObjectFiles.addAll(extractSoFilesFromGzipArchive(symbolArchives, copyDir)) + sharedObjectFiles.addAll(extractSoFilesFromZipArchive(symbolArchives, copyDir)) logger.info("Extracted Unity SO files: $sharedObjectFiles") // generate mapping files for each SO file sharedObjectFiles.forEach { sharedObjectFile -> - generateUnitySoMappingFile(sharedObjectFile) + val abi = Abi.findByName(sharedObjectFile.parentFile.name)!! + generateMappingFile(sharedObjectFile, abi) } } + override fun objdump(inputFile: File, abi: Abi): ProcessBuilder { + val objdump = ndkToolchain.get().objdumpForAbi(abi).path + return ProcessBuilder( + objdump, + "--sym", + inputFile.path + ) + } + /** - * Extracts the libunity/libil2cpp SO files from inside a GZIP archive, + * Extracts the libunity/libil2cpp SO files from inside a ZIP archive, * which is where the files are located for exported Gradle projects */ - private fun extractSoFilesFromGzipArchive(symbolArchives: List, copyDir: File): List { + private fun extractSoFilesFromZipArchive(symbolArchives: List, copyDir: File): List { copyDir.mkdirs() return symbolArchives.flatMap { archive -> val zipFile = ZipFile(archive) @@ -158,18 +153,6 @@ internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( return dst } - private fun generateUnitySoMappingFile(sharedObjectFile: File) { - val arch = sharedObjectFile.parentFile.name - val params = SharedObjectMappingFileFactory.Params( - sharedObjectFile, - requireNotNull(Abi.findByName(arch)), - objDumpPaths.get(), - intermediateOutputDir.get().asFile, - SharedObjectMappingFileFactory.SharedObjectType.UNITY - ) - SharedObjectMappingFileFactory.generateSoMappingFile(project, params) - } - /** * The directory below the exported symbols. When Unity exports a project to an Android Gradle project * the symbols are exported as an archive in the same directory. @@ -202,17 +185,16 @@ internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( fun register( project: Project, output: ApkVariantOutput, - objdumpPaths: Provider>, + ndk: NdkToolchain, mappingFileOutputDir: String, copyOutputDir: String ) = register(project, output) { variantOutput = output - objDumpPaths.set(objdumpPaths) - manifestInfo.set(BugsnagManifestUuidTask.manifestInfoForOutput(project, output)) + ndkToolchain.set(ndk) rootProjectDir.set(project.rootProject.projectDir) - intermediateOutputDir.set(project.layout.buildDirectory.dir(mappingFileOutputDir)) + outputDirectory.set(project.layout.buildDirectory.dir(mappingFileOutputDir)) unitySharedObjectDir.set(project.layout.buildDirectory.dir(copyOutputDir)) - }.dependsOn(BugsnagManifestUuidTask.forBuildOutput(project, output)) + } internal fun isUnitySymbolsArchive(name: String, projectName: String): Boolean { return name.endsWith("symbols.zip") && name.startsWith(projectName) @@ -224,6 +206,33 @@ internal open class BugsnagGenerateUnitySoMappingTask @Inject constructor( return extensionMatch && nameMatch } + /** + * Determines whether SO mapping files should be generated for the + * libunity.so file in Unity projects. + */ + @Suppress("SENSELESS_COMPARISON") + internal fun isUnityLibraryUploadEnabled( + bugsnag: BugsnagPluginExtension, + android: BaseExtension + ): Boolean { + val enabled = bugsnag.uploadNdkUnityLibraryMappings.orNull + return when { + enabled != null -> enabled + else -> { + // workaround to avoid exception as noCompress was null until AGP 4.1 + runCatching { + val clz = android.aaptOptions.javaClass + val method = clz.getMethod("getNoCompress") + val noCompress = method.invoke(android.aaptOptions) + if (noCompress is Collection<*>) { + return noCompress.contains(".unity3d") + } + } + return false + } + } + } + override fun taskNameFor(variantOutputName: String) = "generateBugsnagUnity${variantOutputName.capitalize()}Mapping" } diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagMultiPartUploadRequest.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagMultiPartUploadRequest.kt index 4d8ce78a..86d96ce0 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagMultiPartUploadRequest.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagMultiPartUploadRequest.kt @@ -4,21 +4,12 @@ import com.bugsnag.android.gradle.internal.runRequestWithRetries import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import org.gradle.api.DefaultTask import java.io.IOException /** - * Task to upload ProGuard mapping files to Bugsnag. - * - * Reads meta-data tags from the project's AndroidManifest.xml to extract a - * build UUID (injected by BugsnagManifestTask) and a Bugsnag API Key: - * - * https://developer.android.com/guide/topics/manifest/manifest-intro.html - * https://developer.android.com/guide/topics/manifest/meta-data-element.html - * - * This task must be called after ProGuard mapping files are generated, so - * it is usually safe to have this be the absolute last task executed during - * a build. + * Wrapper for common Bugsnag HTTP multipart upload behaviours. */ class BugsnagMultiPartUploadRequest( private val failOnUploadError: Boolean, @@ -28,13 +19,10 @@ class BugsnagMultiPartUploadRequest( ) { fun uploadMultipartEntity( - manifestInfo: AndroidManifestInfo, retryCount: Int, - action: (MultipartBody.Builder) -> Unit + bodyBuilder: BugsnagMultiPartUploadRequest.(MultipartBody.Builder) -> Unit ): String { - val builder = buildMultipartBody(manifestInfo, overwrite) - action(builder) - val body = builder.build() + val body = createMultipartBody(bodyBuilder) return try { runRequestWithRetries(retryCount) { @@ -48,7 +36,21 @@ class BugsnagMultiPartUploadRequest( } } - private fun uploadToServer(body: MultipartBody): String? { + fun createMultipartBody(bodyBuilder: BugsnagMultiPartUploadRequest.(MultipartBody.Builder) -> Unit): MultipartBody { + return buildMultipartBody(overwrite) + .also { bodyBuilder(it) } + .build() + } + + fun MultipartBody.Builder.addAndroidManifestInfo(manifestInfo: AndroidManifestInfo): MultipartBody.Builder { + return addFormDataPart("apiKey", manifestInfo.apiKey) + .addFormDataPart("appId", manifestInfo.applicationId) + .addFormDataPart("versionCode", manifestInfo.versionCode) + .addFormDataPart("versionName", manifestInfo.versionName) + .addFormDataPart("buildUUID", manifestInfo.buildUUID) + } + + fun uploadRequest(body: MultipartBody, responseHandler: (Response) -> R): R { // Make the request val request = Request.Builder() .url(endpoint) @@ -56,28 +58,30 @@ class BugsnagMultiPartUploadRequest( .build() okHttpClient.newCall(request).execute().use { response -> + return responseHandler(response) + } + } + + private fun uploadToServer(body: MultipartBody): String? { + return uploadRequest(body) { response -> if (!response.isSuccessful) { throw IOException("Bugsnag upload failed with code ${response.code}") } - return response.body?.string() + + response.body?.string() } } companion object { - internal fun buildMultipartBody(manifestInfo: AndroidManifestInfo, overwrite: Boolean): MultipartBody.Builder { - val builder = MultipartBody.Builder() + internal fun buildMultipartBody(overwrite: Boolean): MultipartBody.Builder { + return MultipartBody.Builder() + .apply { + if (overwrite) { + addFormDataPart("overwrite", "true") + } + } .setType(MultipartBody.FORM) - .addFormDataPart("apiKey", manifestInfo.apiKey) - .addFormDataPart("appId", manifestInfo.applicationId) - .addFormDataPart("versionCode", manifestInfo.versionCode) - .addFormDataPart("buildUUID", manifestInfo.buildUUID) - .addFormDataPart("versionName", manifestInfo.versionName) - - if (overwrite) { - builder.addFormDataPart("overwrite", "true") - } - return builder } internal fun from( diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPlugin.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPlugin.kt index 90bed326..7b08edbb 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPlugin.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPlugin.kt @@ -13,6 +13,7 @@ import com.bugsnag.android.gradle.internal.AgpVersions import com.bugsnag.android.gradle.internal.BugsnagHttpClientHelper import com.bugsnag.android.gradle.internal.ExternalNativeBuildTaskUtil import com.bugsnag.android.gradle.internal.NDK_SO_MAPPING_DIR +import com.bugsnag.android.gradle.internal.NdkToolchain import com.bugsnag.android.gradle.internal.TASK_JNI_LIBS import com.bugsnag.android.gradle.internal.UNITY_SO_COPY_DIR import com.bugsnag.android.gradle.internal.UNITY_SO_MAPPING_DIR @@ -192,13 +193,17 @@ class BugsnagPlugin : Plugin { ndkUploadClientProvider: Provider, unityUploadClientProvider: Provider ) { + val ndkToolchain by lazy(LazyThreadSafetyMode.NONE) { + NdkToolchain.configureNdkToolkit(project, bugsnag, variant) + } + variant.outputs.configureEach { output -> check(output is ApkVariantOutput) { "Expected variant output to be ApkVariantOutput but found ${output.javaClass}" } val jvmMinificationEnabled = project.isJvmMinificationEnabled(variant) val ndkEnabled = isNdkUploadEnabled(bugsnag, android) - val unityEnabled = isUnityLibraryUploadEnabled(bugsnag, android) + val unityEnabled = BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android) val reactNativeEnabled = isReactNativeUploadEnabled(bugsnag) // register bugsnag tasks @@ -220,6 +225,7 @@ class BugsnagPlugin : Plugin { bugsnag.failOnUploadError, mappingFilesProvider ) + else -> null } @@ -233,6 +239,7 @@ class BugsnagPlugin : Plugin { proguardUploadClientProvider, generateProguardTaskProvider ).dependsOn(manifestTaskProvider) + else -> null } val ndkSoMappingOutput = "$NDK_SO_MAPPING_DIR/${output.name}" @@ -242,23 +249,24 @@ class BugsnagPlugin : Plugin { project, variant, output, - bugsnag.objdumpPaths, + ndkToolchain, getSharedObjectSearchPaths(project, bugsnag, android), ndkSoMappingOutput ) + else -> null } val uploadNdkMappingProvider = when { - ndkEnabled && generateNdkMappingProvider != null -> { - BugsnagUploadSharedObjectTask.registerUploadNdkTask( - project, - output, - httpClientHelperProvider, - ndkUploadClientProvider, - generateNdkMappingProvider, - ndkSoMappingOutput - ) - } + ndkEnabled && generateNdkMappingProvider != null -> BugsnagUploadSoSymTask.register( + project, + output, + ndkToolchain, + BugsnagUploadSoSymTask.UploadType.NDK, + generateNdkMappingProvider, + httpClientHelperProvider, + ndkUploadClientProvider + ) + else -> null } @@ -269,23 +277,26 @@ class BugsnagPlugin : Plugin { BugsnagGenerateUnitySoMappingTask.register( project, output, - bugsnag.objdumpPaths, + ndkToolchain, unityMappingDir, "$UNITY_SO_COPY_DIR/${output.name}" ) + else -> null } val uploadUnityMappingProvider = when { unityEnabled && generateUnityMappingProvider != null -> { - BugsnagUploadSharedObjectTask.registerUploadUnityTask( + BugsnagUploadSoSymTask.register( project, output, - httpClientHelperProvider, - unityUploadClientProvider, + ndkToolchain, + BugsnagUploadSoSymTask.UploadType.UNITY, generateUnityMappingProvider, - unityMappingDir + httpClientHelperProvider, + unityUploadClientProvider ) } + else -> null } @@ -297,6 +308,7 @@ class BugsnagPlugin : Plugin { bugsnag, manifestInfoProvider )?.dependsOn(manifestTaskProvider) + else -> null } @@ -565,40 +577,13 @@ class BugsnagPlugin : Plugin { } } - /** - * Determines whether SO mapping files should be generated for the - * libunity.so file in Unity projects. - */ - @Suppress("SENSELESS_COMPARISON") - internal fun isUnityLibraryUploadEnabled( - bugsnag: BugsnagPluginExtension, - android: BaseExtension - ): Boolean { - val enabled = bugsnag.uploadNdkUnityLibraryMappings.orNull - return when { - enabled != null -> enabled - else -> { - // workaround to avoid exception as noCompress was null until AGP 4.1 - runCatching { - val clz = android.aaptOptions.javaClass - val method = clz.getMethod("getNoCompress") - val noCompress = method.invoke(android.aaptOptions) - if (noCompress is Collection<*>) { - return noCompress.contains(".unity3d") - } - } - return false - } - } - } - internal fun isNdkUploadEnabled( bugsnag: BugsnagPluginExtension, android: BaseExtension ): Boolean { val usesCmake = android.externalNativeBuild.cmake.path != null val usesNdkBuild = android.externalNativeBuild.ndkBuild.path != null - val unityEnabled = isUnityLibraryUploadEnabled(bugsnag, android) + val unityEnabled = BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android) val default = usesCmake || usesNdkBuild || unityEnabled return bugsnag.uploadNdkMappings.getOrElse(default) } @@ -623,7 +608,7 @@ class BugsnagPlugin : Plugin { android: BaseExtension ): List { val searchPaths = bugsnag.sharedObjectPaths.get().toMutableList() - val unityEnabled = isUnityLibraryUploadEnabled(bugsnag, android) + val unityEnabled = BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android) val ndkEnabled = isNdkUploadEnabled(bugsnag, android) if (unityEnabled && ndkEnabled) { diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPluginExtension.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPluginExtension.kt index 8b1606e6..55411860 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPluginExtension.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagPluginExtension.kt @@ -85,6 +85,8 @@ open class BugsnagPluginExtension @Inject constructor(objects: ObjectFactory) { val objdumpPaths: MapProperty = objects.mapProperty() .convention(emptyMap()) + val useLegacyNdkSymbolUpload: Property = objects.property().convention(true) + // exposes sourceControl as a nested object on the extension, // see https://docs.gradle.org/current/userguide/custom_gradle_types.html#nested_objects diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadProguardTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadProguardTask.kt index 0ea92b03..5da19ec1 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadProguardTask.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadProguardTask.kt @@ -85,8 +85,9 @@ open class BugsnagUploadProguardTask @Inject constructor( val request = BugsnagMultiPartUploadRequest.from(this) val mappingFileHash = mappingFile.md5HashCode() val response = uploadRequestClient.get().makeRequestIfNeeded(manifestInfo, mappingFileHash) { - request.uploadMultipartEntity(manifestInfo, retryCount.get()) { builder -> + request.uploadMultipartEntity(retryCount.get()) { builder -> logger.lifecycle("Bugsnag: Uploading JVM mapping file from: $mappingFile") + builder.addAndroidManifestInfo(manifestInfo) builder.addFormDataPart("proguard", mappingFile.name, mappingFile.asRequestBody()) } } diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSharedObjectTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSharedObjectTask.kt deleted file mode 100644 index eefcf7ad..00000000 --- a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSharedObjectTask.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.bugsnag.android.gradle - -import com.android.build.gradle.api.BaseVariantOutput -import com.bugsnag.android.gradle.internal.BugsnagHttpClientHelper -import com.bugsnag.android.gradle.internal.UploadRequestClient -import com.bugsnag.android.gradle.internal.dependsOn -import com.bugsnag.android.gradle.internal.forBuildOutput -import com.bugsnag.android.gradle.internal.intermediateForNdkSoRequest -import com.bugsnag.android.gradle.internal.intermediateForUnitySoRequest -import com.bugsnag.android.gradle.internal.md5HashCode -import com.bugsnag.android.gradle.internal.property -import com.bugsnag.android.gradle.internal.taskNameForUploadNdkMapping -import com.bugsnag.android.gradle.internal.taskNameForUploadUnityMapping -import okhttp3.RequestBody.Companion.asRequestBody -import org.gradle.api.DefaultTask -import org.gradle.api.Project -import org.gradle.api.Task -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFile -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.TaskProvider -import java.io.File -import javax.inject.Inject - -/** - * Task that uploads shared object mapping files to Bugsnag. - */ -internal open class BugsnagUploadSharedObjectTask @Inject constructor( - objects: ObjectFactory, -) : DefaultTask(), AndroidManifestInfoReceiver, BugsnagFileUploadTask { - - enum class UploadType(private val path: String, val uploadKey: String) { - NDK("so-symbol", "soSymbolFile"), - UNITY("so-symbol-table", "soSymbolTableFile"); - - fun endpoint(base: String): String { - return "$base/$path" - } - } - - init { - group = BugsnagPlugin.GROUP_NAME - description = "Uploads SO mapping files to Bugsnag" - } - - @get:InputFile - override val manifestInfo: RegularFileProperty = objects.fileProperty() - - @get:Internal - internal val uploadRequestClient: Property = objects.property() - - @get:Internal - override val httpClientHelper: Property = objects.property() - - @Input - val projectRoot: Property = objects.property() - - @get:OutputFile - val requestOutputFile: RegularFileProperty = objects.fileProperty() - - @get:InputDirectory - val intermediateOutputDir: DirectoryProperty = objects.directoryProperty() - - @get:Input - override val failOnUploadError: Property = objects.property() - - @get:Input - override val overwrite: Property = objects.property() - - @get:Input - override val endpoint: Property = objects.property() - - @get:Input - override val retryCount: Property = objects.property() - - @get:Input - override val timeoutMillis: Property = objects.property() - - @get:Input - val uploadType: Property = objects.property() - - @TaskAction - fun upload() { - val rootDir = intermediateOutputDir.asFile.get() - val abiDirs = rootDir.listFiles().filter { it.isDirectory } - logger.info("Bugsnag: Found shared object files for upload: $abiDirs") - - abiDirs.forEach { abiDir -> - val arch = abiDir.name - abiDir.listFiles() - .filter { it.extension == "gz" } - .forEach { sharedObjectFile -> - uploadSymbols(sharedObjectFile, arch) - } - } - } - - /** - * Uploads the given shared object mapping information - * @param mappingFile the file to upload - * @param arch the arch that is being uploaded - */ - private fun uploadSymbols(mappingFile: File, arch: String) { - // a SO file may not contain debug info. if that's the case then the mapping file should be very small, - // so we try and reject it here as otherwise the event-worker will reject it with a 400 status code. - if (!mappingFile.exists() || mappingFile.length() < VALID_SO_FILE_THRESHOLD) { - logger.warn("Bugsnag: Skipping upload of empty/invalid mapping file: $mappingFile") - return - } - val sharedObjectName = mappingFile.nameWithoutExtension - val requestEndpoint = uploadType.get().endpoint(endpoint.get()) - val soUploadKey = uploadType.get().uploadKey - - val request = BugsnagMultiPartUploadRequest.from(this, requestEndpoint) - val manifestInfo = parseManifestInfo() - val mappingFileHash = mappingFile.md5HashCode() - val response = uploadRequestClient.get().makeRequestIfNeeded(manifestInfo, mappingFileHash) { - logger.lifecycle( - "Bugsnag: Uploading SO mapping file for " + - "$sharedObjectName ($arch) from $mappingFile" - ) - request.uploadMultipartEntity(manifestInfo, retryCount.get()) { builder -> - builder.addFormDataPart(soUploadKey, mappingFile.name, mappingFile.asRequestBody()) - builder.addFormDataPart("arch", arch) - builder.addFormDataPart("sharedObjectName", sharedObjectName) - builder.addFormDataPart("projectRoot", projectRoot.get()) - } - } - requestOutputFile.asFile.get().writeText(response) - } - - companion object { - private const val VALID_SO_FILE_THRESHOLD = 1024 - - @Suppress("LongParameterList") - fun registerUploadNdkTask( - project: Project, - output: BaseVariantOutput, - httpClientHelperProvider: Provider, - ndkUploadClientProvider: Provider, - generateTaskProvider: TaskProvider, - soMappingOutputDir: String - ): TaskProvider { - return register( - project, - generateTaskProvider, - httpClientHelperProvider, - BugsnagManifestUuidTask.manifestInfoForOutput(project, output), - ndkUploadClientProvider, - taskNameForUploadNdkMapping(output), - intermediateForNdkSoRequest(project, output), - UploadType.NDK, - soMappingOutputDir - ).dependsOn(BugsnagManifestUuidTask.forBuildOutput(project, output)) - } - - @Suppress("LongParameterList") - fun registerUploadUnityTask( - project: Project, - output: BaseVariantOutput, - httpClientHelperProvider: Provider, - ndkUploadClientProvider: Provider, - generateTaskProvider: TaskProvider, - mappingFileOutputDir: String - ): TaskProvider { - return register( - project, - generateTaskProvider, - httpClientHelperProvider, - BugsnagManifestUuidTask.manifestInfoForOutput(project, output), - ndkUploadClientProvider, - taskNameForUploadUnityMapping(output), - intermediateForUnitySoRequest(project, output), - UploadType.UNITY, - mappingFileOutputDir - ).dependsOn(BugsnagManifestUuidTask.forBuildOutput(project, output)) - } - - @Suppress("LongParameterList") - fun register( - project: Project, - generateTaskProvider: TaskProvider, - httpClientHelperProvider: Provider, - manifestInfoProvider: Provider, - ndkUploadClientProvider: Provider, - taskName: String, - requestOutputFile: Provider, - uploadType: UploadType, - intermediateOutputPath: String - ): TaskProvider { - val bugsnag = project.extensions.getByType(BugsnagPluginExtension::class.java) - // Create a Bugsnag task to upload NDK mapping file(s) - return project.tasks.register(taskName, BugsnagUploadSharedObjectTask::class.java) { task -> - // upload task requires SO mapping generation to occur first - task.dependsOn(generateTaskProvider) - task.usesService(httpClientHelperProvider) - task.usesService(ndkUploadClientProvider) - - task.requestOutputFile.set(requestOutputFile) - task.uploadType.set(uploadType) - task.projectRoot.set(bugsnag.projectRoot.getOrElse(project.projectDir.toString())) - task.httpClientHelper.set(httpClientHelperProvider) - task.manifestInfo.set(manifestInfoProvider) - task.uploadRequestClient.set(ndkUploadClientProvider) - task.intermediateOutputDir.set(project.layout.buildDirectory.dir(intermediateOutputPath)) - task.configureWith(bugsnag) - } - } - } -} diff --git a/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSoSymTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSoSymTask.kt new file mode 100644 index 00000000..7d3067b0 --- /dev/null +++ b/src/main/kotlin/com/bugsnag/android/gradle/BugsnagUploadSoSymTask.kt @@ -0,0 +1,210 @@ +package com.bugsnag.android.gradle + +import com.android.build.gradle.api.BaseVariantOutput +import com.bugsnag.android.gradle.internal.AbstractSoMappingTask +import com.bugsnag.android.gradle.internal.BugsnagHttpClientHelper +import com.bugsnag.android.gradle.internal.NdkToolchain +import com.bugsnag.android.gradle.internal.UploadRequestClient +import com.bugsnag.android.gradle.internal.md5HashCode +import com.bugsnag.android.gradle.internal.taskNameSuffix +import okhttp3.RequestBody.Companion.asRequestBody +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.StopExecutionException +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import java.io.File +import java.net.HttpURLConnection.HTTP_NOT_FOUND + +internal abstract class BugsnagUploadSoSymTask : DefaultTask(), AndroidManifestInfoReceiver, BugsnagFileUploadTask { + + @get:InputDirectory + abstract val symbolFilesDir: DirectoryProperty + + @get:OutputFile + abstract val requestOutputFile: RegularFileProperty + + @get:Input + abstract val projectRoot: Property + + @get:Nested + abstract val ndkToolchain: Property + + @get:Input + @get:Optional + abstract val uploadType: Property + + @get:Internal + internal abstract val uploadRequestClient: Property + + init { + group = BugsnagPlugin.GROUP_NAME + description = "Uploads SO Symbol files to Bugsnag" + } + + @TaskAction + fun upload() { + val rootDir = symbolFilesDir.asFile.get() + logger.info("Bugsnag: Found shared object files for upload: $rootDir") + + if (ndkToolchain.get().preferredMappingTool() == NdkToolchain.MappingTool.OBJDUMP) { + // uploadType == objdump + val abiDirs = rootDir.listFiles().filter { it.isDirectory } + abiDirs.forEach { abiDir -> + val arch = abiDir.name + abiDir.listFiles() + .filter { it.extension == "gz" } + .forEach { sharedObjectFile -> + uploadObjdump(sharedObjectFile, arch) + } + } + } else { + rootDir.walkTopDown() + .filter { it.isFile && it.extension == "gz" && it.length() >= VALID_SO_FILE_THRESHOLD } + .forEach { uploadSymbols(it) } + } + } + + /** + * Uploads the given shared object mapping information + * @param mappingFile the file to upload + */ + private fun uploadSymbols(mappingFile: File) { + val sharedObjectName = mappingFile.nameWithoutExtension + val requestEndpoint = endpoint.get() + ENDPOINT_SUFFIX + + val request = BugsnagMultiPartUploadRequest.from(this, requestEndpoint) + val manifestInfo = parseManifestInfo() + val mappingFileHash = mappingFile.md5HashCode() + + val response = uploadRequestClient.get().makeRequestIfNeeded(manifestInfo, mappingFileHash) { + logger.lifecycle("Bugsnag: Uploading SO mapping file from $mappingFile") + val body = request.createMultipartBody { builder -> + builder + .addFormDataPart("apiKey", manifestInfo.apiKey) + .addFormDataPart("appId", manifestInfo.applicationId) + .addFormDataPart("versionCode", manifestInfo.versionCode) + .addFormDataPart("versionName", manifestInfo.versionName) + .addFormDataPart("soFile", mappingFile.name, mappingFile.asRequestBody()) + .addFormDataPart("sharedObjectName", sharedObjectName) + .addFormDataPart("projectRoot", projectRoot.get()) + } + + request.uploadRequest(body) { response -> + if (response.code == HTTP_NOT_FOUND && endpoint.get() != UPLOAD_ENDPOINT_DEFAULT) { + throw StopExecutionException( + "Bugsnag instance does not support the new NDK symbols upload mechanism. " + + "Please set legacyNDKSymbolsUpload or upgrade your Bugsnag instance. " + + "See https://docs.bugsnag.com/api/ndk-symbol-mapping-upload/ for details." + ) + } + + if (!response.isSuccessful) { + "Failure" + } else { + response.body!!.string() + } + } + } + requestOutputFile.asFile.get().writeText(response) + } + + private fun uploadObjdump(mappingFile: File, arch: String) { + // a SO file may not contain debug info. if that's the case then the mapping file should be very small, + // so we try and reject it here as otherwise the event-worker will reject it with a 400 status code. + if (!mappingFile.exists() || mappingFile.length() < VALID_SO_FILE_THRESHOLD) { + logger.warn("Bugsnag: Skipping upload of empty/invalid mapping file: $mappingFile") + return + } + + val sharedObjectName = mappingFile.nameWithoutExtension + val requestEndpoint = uploadType.get().endpoint(endpoint.get()) + val soUploadKey = uploadType.get().uploadKey + + val request = BugsnagMultiPartUploadRequest.from(this, requestEndpoint) + val manifestInfo = parseManifestInfo() + val mappingFileHash = mappingFile.md5HashCode() + val response = uploadRequestClient.get().makeRequestIfNeeded(manifestInfo, mappingFileHash) { + logger.lifecycle( + "Bugsnag: Uploading SO mapping file for $sharedObjectName ($arch) from $mappingFile" + ) + request.uploadMultipartEntity(retryCount.get()) { builder -> + builder.addAndroidManifestInfo(manifestInfo) + builder.addFormDataPart(soUploadKey, mappingFile.name, mappingFile.asRequestBody()) + builder.addFormDataPart("arch", arch) + builder.addFormDataPart("sharedObjectName", sharedObjectName) + builder.addFormDataPart("projectRoot", projectRoot.get()) + } + } + requestOutputFile.asFile.get().writeText(response) + } + + companion object { + private const val ENDPOINT_SUFFIX = "/ndk-symbol" + + private const val VALID_SO_FILE_THRESHOLD = 1024 + + fun taskNameFor(variant: BaseVariantOutput, uploadType: UploadType) = + "uploadBugsnag${uploadType.name.toLowerCase().capitalize()}${variant.baseName.capitalize()}Mapping" + + internal fun requestOutputFileFor(project: Project, output: BaseVariantOutput): Provider { + val path = "intermediates/bugsnag/requests/symFor${output.taskNameSuffix()}.json" + return project.layout.buildDirectory.file(path) + } + + fun register( + project: Project, + variant: BaseVariantOutput, + ndkToolchain: NdkToolchain, + uploadType: UploadType, + generateTaskProvider: TaskProvider, + httpClientHelperProvider: Provider, + ndkUploadClientProvider: Provider, + ): TaskProvider { + val bugsnag = project.extensions.getByType(BugsnagPluginExtension::class.java) + return project.tasks.register( + taskNameFor(variant, uploadType), + BugsnagUploadSoSymTask::class.java + ) { task -> + task.dependsOn(generateTaskProvider) + task.usesService(httpClientHelperProvider) + task.usesService(ndkUploadClientProvider) + + task.endpoint.set(bugsnag.endpoint) + + task.uploadType.set(uploadType) + task.ndkToolchain.set(ndkToolchain) + + task.manifestInfo.set(BugsnagManifestUuidTask.manifestInfoForOutput(project, variant)) + task.symbolFilesDir.set(generateTaskProvider.flatMap { it.outputDirectory }) + task.requestOutputFile.set(requestOutputFileFor(project, variant)) + task.projectRoot.set(bugsnag.projectRoot.getOrElse(project.projectDir.toString())) + + task.httpClientHelper.set(httpClientHelperProvider) + task.uploadRequestClient.set(ndkUploadClientProvider) + + task.configureWith(bugsnag) + } + } + } + + enum class UploadType(private val path: String, val uploadKey: String) { + NDK("so-symbol", "soSymbolFile"), + UNITY("so-symbol-table", "soSymbolTableFile"); + + fun endpoint(base: String): String { + return "$base/$path" + } + } +} diff --git a/src/main/kotlin/com/bugsnag/android/gradle/SharedObjectMappingFileFactory.kt b/src/main/kotlin/com/bugsnag/android/gradle/SharedObjectMappingFileFactory.kt deleted file mode 100644 index 95ee6e39..00000000 --- a/src/main/kotlin/com/bugsnag/android/gradle/SharedObjectMappingFileFactory.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.bugsnag.android.gradle - -import com.android.build.gradle.BaseExtension -import com.bugsnag.android.gradle.SharedObjectMappingFileFactory.SharedObjectType.NDK -import com.bugsnag.android.gradle.SharedObjectMappingFileFactory.SharedObjectType.UNITY -import com.bugsnag.android.gradle.internal.outputZipFile -import org.apache.tools.ant.taskdefs.condition.Os -import org.gradle.api.Project -import java.io.File - -/** - * Generates a mapping file for the supplied shared object file. - * - * Currently this only supports NDK SO mapping files but in future this will support - * other platforms which require different SO mapping support. - */ -internal object SharedObjectMappingFileFactory { - - enum class SharedObjectType { - NDK, - UNITY - } - - internal data class Params( - val sharedObject: File, - val abi: Abi, - val objDumpPaths: Map, - val outputDirectory: File, - val sharedObjectType: SharedObjectType = NDK - ) - - /** - * Uses objdump to create a symbols file for the given shared object file. - * - * @param project the gradle project - * @param params the parameters required to generate a SO mapping file - * @return the output file location, or null on error - */ - fun generateSoMappingFile(project: Project, params: Params): File? { - // Get the path the version of objdump to use to get symbols - val arch = params.abi.abiName - val objDumpPath = getObjDumpExecutable(project, params.objDumpPaths, arch) - val logger = project.logger - - if (objDumpPath == null) { - logger.error("Bugsnag: Unable to upload NDK symbols: Could not find objdump location for $arch") - return null - } - - try { - val archDir = prepareArchDirectory(params, arch) - val sharedObjectName = params.sharedObject.name - val dst = File(archDir, "$sharedObjectName.gz") - val processBuilder = getObjDumpCommand(objDumpPath, params) - logger.info( - "Bugsnag: Creating symbol file for $sharedObjectName at $dst," + - "running ${processBuilder.command()}" - ) - makeSoMappingFile(dst, processBuilder) - return dst - } catch (e: Exception) { - logger.error("Bugsnag: failed to generate symbols for $arch ${e.message}", e) - } - return null - } - - /** - * Gets the command used to generate the SO mapping file with objdump. - * This differs for NDK and Unity SO files. - */ - private fun getObjDumpCommand(objDumpPath: File, params: Params): ProcessBuilder { - val soPath = params.sharedObject.path - val objdump = objDumpPath.path - return when (params.sharedObjectType) { - NDK -> ProcessBuilder(objdump, "--dwarf=info", "--dwarf=rawline", soPath) - UNITY -> ProcessBuilder(objdump, "--sym", soPath) - } - } - - private fun makeSoMappingFile(dst: File, processBuilder: ProcessBuilder) { - // ensure any errors are dumped to stderr - processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT) - val process = processBuilder.start() - outputZipFile(process.inputStream, dst) - - val exitCode = process.waitFor() - if (exitCode != 0) { - throw IllegalStateException( - "Failed to generate symbols for $dst," + - " objdump exited with code $exitCode" - ) - } - } - - private fun prepareArchDirectory(params: Params, arch: String): File { - val rootDir = params.outputDirectory - return File(rootDir, arch).apply { - mkdir() - } - } - - /** - * Gets the path to the objdump executable to use to get symbols from a shared object - * @param arch The arch of the shared object - * @return The objdump executable, or null if not found - */ - private fun getObjDumpExecutable(project: Project, objDumpPaths: Map, arch: String): File? { - try { - val override = getObjDumpOverride(objDumpPaths, arch) - val objDumpFile: File - objDumpFile = override?.let { File(it) } ?: findObjDump(project, arch) - check((objDumpFile.exists() && objDumpFile.canExecute())) { - "Failed to find executable objdump at $objDumpFile" - } - return objDumpFile - } catch (ex: Throwable) { - project.logger.error("Bugsnag: Error attempting to calculate objdump location: " + ex.message) - } - return null - } - - private fun getObjDumpOverride(objDumpPaths: Map, arch: String) = objDumpPaths[arch] - - private fun findObjDump(project: Project, arch: String): File { - val abi = Abi.findByName(arch) - val android = project.extensions.getByType(BaseExtension::class.java) - val ndkDir = android.ndkDirectory.absolutePath - val osName = calculateOsName() - checkNotNull(abi) { "Failed to find ABI for $arch" } - checkNotNull(osName) { "Failed to calculate OS name" } - return calculateObjDumpLocation(ndkDir, abi, osName) - } - - @JvmStatic - fun calculateObjDumpLocation(ndkDir: String?, abi: Abi, osName: String): File { - val executable = if (osName.startsWith("windows")) "objdump.exe" else "objdump" - return File( - "$ndkDir/toolchains/${abi.toolchainPrefix}-4.9/prebuilt/" + - "$osName/bin/${abi.objdumpPrefix}-$executable" - ) - } - - private fun calculateOsName(): String? { - return when { - Os.isFamily(Os.FAMILY_MAC) -> "darwin-x86_64" - Os.isFamily(Os.FAMILY_UNIX) -> "linux-x86_64" - Os.isFamily(Os.FAMILY_WINDOWS) -> { - when { - "x86" == System.getProperty("os.arch") -> "windows" - else -> "windows-x86_64" - } - } - else -> null - } - } -} diff --git a/src/main/kotlin/com/bugsnag/android/gradle/internal/AbstractSoMappingTask.kt b/src/main/kotlin/com/bugsnag/android/gradle/internal/AbstractSoMappingTask.kt new file mode 100644 index 00000000..1a0e7dd4 --- /dev/null +++ b/src/main/kotlin/com/bugsnag/android/gradle/internal/AbstractSoMappingTask.kt @@ -0,0 +1,83 @@ +package com.bugsnag.android.gradle.internal + +import com.bugsnag.android.gradle.Abi +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.OutputDirectory +import java.io.File + +abstract class AbstractSoMappingTask : DefaultTask() { + + @get:Nested + abstract val ndkToolchain: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + protected open fun objcopy(inputFile: File, abi: Abi): ProcessBuilder { + return ProcessBuilder( + ndkToolchain.get().objcopyForAbi(abi).path, + "--compress-debug-sections=zlib", + "--only-keep-debug", + inputFile.path, + "-" // output to stdout + ) + } + + protected open fun objdump(inputFile: File, abi: Abi): ProcessBuilder { + val objdump = ndkToolchain.get().objdumpForAbi(abi).path + return ProcessBuilder( + objdump, + "--dwarf=info", + "--dwarf=rawline", + inputFile.path + ) + } + + fun generateMappingFile(soFile: File, abi: Abi): File? { + try { + val process = when (ndkToolchain.get().preferredMappingTool()) { + NdkToolchain.MappingTool.OBJCOPY -> objcopy(soFile, abi) + NdkToolchain.MappingTool.OBJDUMP -> objdump(soFile, abi) + } + + val dst = outputFileFor(soFile, abi) + makeSoMappingFile(dst, process) + + return dst + } catch (e: Exception) { + logger.error("Bugsnag: failed to generate symbols for $abi ${e.message}", e) + } + + return null + } + + private fun makeSoMappingFile(dst: File, processBuilder: ProcessBuilder) { + // ensure any errors are dumped to stderr + processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT) + val process = processBuilder.start() + outputZipFile(process.inputStream, dst) + + val exitCode = process.waitFor() + if (exitCode != 0) { + throw IllegalStateException( + "Failed to generate symbols for $dst, objdump exited with code $exitCode" + ) + } + } + + protected open fun outputFileFor(soFile: File, abi: Abi): File { + return File(prepareArchDirectory(abi), "${soFile.name}.gz") + } + + private fun prepareArchDirectory(abi: Abi): File { + val rootDir = outputDirectory.get().asFile + return File(rootDir, abi.abiName).apply { + if (!isDirectory) { + mkdir() + } + } + } +} diff --git a/src/main/kotlin/com/bugsnag/android/gradle/internal/BugsnagTasks.kt b/src/main/kotlin/com/bugsnag/android/gradle/internal/BugsnagTasks.kt index dcf81773..6adf4dda 100644 --- a/src/main/kotlin/com/bugsnag/android/gradle/internal/BugsnagTasks.kt +++ b/src/main/kotlin/com/bugsnag/android/gradle/internal/BugsnagTasks.kt @@ -9,12 +9,6 @@ internal const val TASK_JNI_LIBS = "bugsnagInstallJniLibsTask" internal fun taskNameForUploadJvmMapping(output: BaseVariantOutput) = "uploadBugsnag${output.taskNameSuffix()}Mapping" -internal fun taskNameForUploadNdkMapping(output: BaseVariantOutput) = - "uploadBugsnagNdk${output.taskNameSuffix()}Mapping" - -internal fun taskNameForUploadUnityMapping(output: BaseVariantOutput) = - "uploadBugsnagUnity${output.taskNameSuffix()}Mapping" - internal fun taskNameForUploadRelease(output: BaseVariantOutput) = "bugsnagRelease${output.taskNameSuffix()}Task" diff --git a/src/main/kotlin/com/bugsnag/android/gradle/internal/NdkToolchain.kt b/src/main/kotlin/com/bugsnag/android/gradle/internal/NdkToolchain.kt new file mode 100644 index 00000000..bbc53f76 --- /dev/null +++ b/src/main/kotlin/com/bugsnag/android/gradle/internal/NdkToolchain.kt @@ -0,0 +1,217 @@ +package com.bugsnag.android.gradle.internal + +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.gradle.BaseExtension +import com.android.build.gradle.api.ApkVariant +import com.bugsnag.android.gradle.Abi +import com.bugsnag.android.gradle.BugsnagGenerateUnitySoMappingTask +import com.bugsnag.android.gradle.BugsnagPluginExtension +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.Project +import org.gradle.api.logging.Logger +import org.gradle.api.logging.Logging +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.StopExecutionException +import org.gradle.util.VersionNumber +import java.io.File + +abstract class NdkToolchain { + @get:Input + abstract val baseDir: Property + + @get:Input + abstract val useLegacyNdkSymbolUpload: Property + + @get:Input + abstract val overrides: MapProperty + + @get:Input + @get:Optional + abstract val bugsnagNdkVersion: Property + + @get:Input + abstract val variantName: Property + + private val logger: Logger = Logging.getLogger(this::class.java) + + fun preferredMappingTool(): MappingTool { + var legacyUploadRequired = bugsnagNdkVersion.orNull + ?.let { VersionNumber.parse(it) } + ?.let { it < MIN_BUGSNAG_ANDROID_VERSION } + if (legacyUploadRequired == null) { + logger.warn( + "Cannot detect Bugsnag SDK version for variant ${variantName.get()}, assuming a modern version is " + + "being used. This can cause problems with NDK symbols if older versions are being used. " + + "Please either specify the Bugsnag SDK version for ${variantName.get()} directly." + + "See https://docs.bugsnag.com/api/ndk-symbol-mapping-upload/ for details." + ) + + legacyUploadRequired = false + } + + if (!useLegacyNdkSymbolUpload.get() && legacyUploadRequired) { + throw StopExecutionException( + "Your Bugsnag SDK configured for variant ${variantName.get()} does not support the new NDK " + + "symbols upload mechanism. Please set legacyNDKSymbolsUpload or upgrade your " + + "Bugsnag SDK. See https://docs.bugsnag.com/api/ndk-symbol-mapping-upload/ for details." + ) + } + + // useLegacyNdkSymbolUpload force overrides any defaults or options + if (useLegacyNdkSymbolUpload.get()) { + return MappingTool.OBJDUMP + } + + val ndkVersion = version.get() + return when { + ndkVersion >= MIN_NDK_OBJCOPY_VERSION -> MappingTool.OBJCOPY + else -> MappingTool.OBJDUMP + } + } + + /** + * Set all the fields of this `NdkToolchain` based on the given [other] `NdkToolchain` + */ + fun configureWith(other: NdkToolchain) { + baseDir.set(other.baseDir) + overrides.set(other.overrides) + useLegacyNdkSymbolUpload.set(other.useLegacyNdkSymbolUpload) + bugsnagNdkVersion.set(other.bugsnagNdkVersion) + variantName.set(other.variantName) + } + + private fun executableName(cmdName: String): String { + return if (osName?.startsWith("windows") == true) "$cmdName.exe" else cmdName + } + + fun objdumpForAbi(abi: Abi): File { + val objdumpOverrides = overrides.get() + + return objdumpOverrides[abi]?.let { File(it) } ?: File( + baseDir.get(), + "toolchains/${abi.toolchainPrefix}-4.9/prebuilt/" + + "$osName/bin/${abi.objdumpPrefix}-${executableName("objdump")}" + ) + } + + fun objcopyForAbi(abi: Abi): File { + val objcopyOverrides = overrides.get() + + return objcopyOverrides[abi]?.let { File(it) } + ?: locateObjcopy(abi) + } + + private fun locateObjcopy(abi: Abi): File { + val relativeExecutablePath = when { + isLLVMPreferred.get() -> "toolchains/llvm/prebuilt/$osName/bin/${executableName("llvm-objcopy")}" + else -> + "toolchains/${abi.toolchainPrefix}-4.9/prebuilt/" + + "$osName/bin/${abi.objdumpPrefix}-${executableName("objcopy")}" + } + + return File(baseDir.get(), relativeExecutablePath) + } + + enum class MappingTool { + OBJDUMP, + OBJCOPY + } + + companion object { + /** + * Minimum `bugsnag-android` version where the new symbol uploading is available, using `objcopy` to produce + * the symbol files instead of `objdump` + */ + internal val MIN_BUGSNAG_ANDROID_VERSION = VersionNumber.version(5, 26) + + /** + * The minimum NDK version where we will use `objcopy` instead of `objdump` to produce the symbol files + */ + internal val MIN_NDK_OBJCOPY_VERSION = VersionNumber.version(21) + + /** + * The minimum NDK version where we will use the LLVM toolchain instead of the GNU toolchain + */ + internal val MIN_NDK_LLVM_VERSION = VersionNumber.version(23) + + private val osName = when { + Os.isFamily(Os.FAMILY_MAC) -> "darwin-x86_64" + Os.isFamily(Os.FAMILY_UNIX) -> "linux-x86_64" + Os.isFamily(Os.FAMILY_WINDOWS) -> { + when { + "x86" == System.getProperty("os.arch") -> "windows" + else -> "windows-x86_64" + } + } + + else -> null + } + + /* + * SdkComponents.ndkDirectory + * https://developer.android.com/reference/tools/gradle-api/7.2/com/android/build/api/dsl/SdkComponents#ndkDirectory() + * sometimes fails to resolve when ndkPath is not defined (Cannot query the value of this property because it has + * no value available.). This means that even `map` and `isPresent` will break. + * + * So we also fall back use the old BaseExtension if it appears broken + */ + private fun ndkToolchainDirectoryFor(project: Project): Provider { + val extensions = project.extensions + val sdkComponents = extensions.getByType(AndroidComponentsExtension::class.java)?.sdkComponents + + return project.provider { + try { + return@provider sdkComponents!!.ndkDirectory.get().asFile + } catch (e: Exception) { + return@provider extensions.getByType(BaseExtension::class.java).ndkDirectory.absoluteFile + } + } + } + + private fun getBugsnagAndroidNDKVersion(variant: ApkVariant): String? { + return try { + val bugsnagAndroidCoreVersion = variant.compileConfiguration.resolvedConfiguration.resolvedArtifacts + .find { + it.moduleVersion.id.group == "com.bugsnag" && + it.moduleVersion.id.name == "bugsnag-plugin-android-ndk" + } + ?.moduleVersion?.id?.version + + bugsnagAndroidCoreVersion + } catch (e: Exception) { + null + } + } + + fun configureNdkToolkit( + project: Project, + bugsnag: BugsnagPluginExtension, + variant: ApkVariant + ): NdkToolchain { + val useLegacyNdkSymbolUpload = bugsnag.useLegacyNdkSymbolUpload.get() + val overrides = bugsnag.objdumpPaths.map { it.mapKeys { (abi, _) -> Abi.findByName(abi)!! } } + + val ndkToolchain = project.objects.newInstance() + ndkToolchain.baseDir.set(ndkToolchainDirectoryFor(project)) + ndkToolchain.useLegacyNdkSymbolUpload.set(useLegacyNdkSymbolUpload) + ndkToolchain.overrides.set(overrides) + + // we disable the bugsnag-android version check if Unity is enabled otherwise we end up with mutation errors + if (!BugsnagGenerateUnitySoMappingTask + .isUnityLibraryUploadEnabled(bugsnag, project.extensions.findByType(BaseExtension::class.java)!!) + ) { + ndkToolchain.bugsnagNdkVersion.set(project.provider { getBugsnagAndroidNDKVersion(variant) }) + } + ndkToolchain.variantName.set(variant.name) + + return ndkToolchain + } + } +} + +private val NdkToolchain.version get() = baseDir.map { VersionNumber.parse(it.name) } +private val NdkToolchain.isLLVMPreferred get() = version.map { it >= NdkToolchain.MIN_NDK_LLVM_VERSION } diff --git a/src/test/kotlin/com/bugsnag/android/gradle/ObjDumpLocationTest.kt b/src/test/kotlin/com/bugsnag/android/gradle/ObjDumpLocationTest.kt deleted file mode 100644 index 3873cd60..00000000 --- a/src/test/kotlin/com/bugsnag/android/gradle/ObjDumpLocationTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.bugsnag.android.gradle - -import com.bugsnag.android.gradle.SharedObjectMappingFileFactory.calculateObjDumpLocation -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import java.util.ArrayList - -@RunWith(Parameterized::class) -class ObjDumpLocationTest { - - @Parameterized.Parameter(0) - lateinit var abi: Abi - - @Parameterized.Parameter(1) - lateinit var osName: String - - @Parameterized.Parameter(2) - lateinit var ndkDir: String - - @Test - fun testDefaultObjDumpLocation() { - val file = calculateObjDumpLocation(ndkDir, abi, osName) - val exec = when { - osName.startsWith("windows") -> "objdump.exe" - else -> "objdump" - } - val expected = "$ndkDir/toolchains/${abi.toolchainPrefix}-4.9/prebuilt/$osName/bin/${abi.objdumpPrefix}-$exec" - assertEquals(expected, file.path) - } - - companion object { - @Parameterized.Parameters - @JvmStatic - fun inputs(): Collection> { - val inputs: MutableCollection> = ArrayList() - for (abi in Abi.values()) { - for (os in listOf("darwin-x86_64", "linux-x86_64", "windows", "windows-x86_64")) { - for (ndkDir in listOf("/Users/bob/Library/Android/sdk/ndk-bundle", "/etc/ndk-bundle")) { - inputs.add(listOf(abi, os, ndkDir).toTypedArray()) - } - } - } - return inputs - } - - @Parameterized.Parameters - @JvmStatic - fun os(): Collection = listOf("windows", "linux") - } -} diff --git a/src/test/kotlin/com/bugsnag/android/gradle/PluginExtensionTest.kt b/src/test/kotlin/com/bugsnag/android/gradle/PluginExtensionTest.kt index 61b1c2bf..8995a7dc 100644 --- a/src/test/kotlin/com/bugsnag/android/gradle/PluginExtensionTest.kt +++ b/src/test/kotlin/com/bugsnag/android/gradle/PluginExtensionTest.kt @@ -79,7 +79,7 @@ class PluginExtensionTest { // ndk/unity upload defaults to false val plugin = proj.plugins.findPlugin(BugsnagPlugin::class.java)!! - assertFalse(plugin.isUnityLibraryUploadEnabled(bugsnag, android)) + assertFalse(BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android)) assertFalse(plugin.isNdkUploadEnabled(bugsnag, android)) assertFalse(plugin.isReactNativeUploadEnabled(bugsnag)) assertEquals(emptyList(), plugin.getSharedObjectSearchPaths(proj, bugsnag, android)) @@ -143,7 +143,7 @@ class PluginExtensionTest { // ndk/unity upload overridden to true val plugin = proj.plugins.findPlugin(BugsnagPlugin::class.java)!! - assertTrue(plugin.isUnityLibraryUploadEnabled(bugsnag, android)) + assertTrue(BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android)) assertTrue(plugin.isNdkUploadEnabled(bugsnag, android)) assertTrue(plugin.isReactNativeUploadEnabled(bugsnag)) assertEquals("http://localhost:1234", bugsnag.endpoint.get()) @@ -168,7 +168,7 @@ class PluginExtensionTest { `when`(cmake.path).thenReturn(File("/users/sdk/cmake")) // ndk/unity upload overridden to true - assertTrue(plugin.isUnityLibraryUploadEnabled(bugsnag, android)) + assertTrue(BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android)) assertTrue(plugin.isNdkUploadEnabled(bugsnag, android)) val expected = listOf( File(proj.projectDir, "src/main/jniLibs"), @@ -198,7 +198,7 @@ class PluginExtensionTest { `when`(aaptOptions.noCompress).thenReturn(mutableListOf(".unity3d")) // ndk/unity uploads overridden to true - assertTrue(plugin.isUnityLibraryUploadEnabled(bugsnag, android)) + assertTrue(BugsnagGenerateUnitySoMappingTask.isUnityLibraryUploadEnabled(bugsnag, android)) assertTrue(plugin.isNdkUploadEnabled(bugsnag, android)) val expected = listOf( File(proj.projectDir, "src/main/jniLibs"), diff --git a/src/test/kotlin/com/bugsnag/android/gradle/internal/NdkToolchainTest.kt b/src/test/kotlin/com/bugsnag/android/gradle/internal/NdkToolchainTest.kt new file mode 100644 index 00000000..e84005f3 --- /dev/null +++ b/src/test/kotlin/com/bugsnag/android/gradle/internal/NdkToolchainTest.kt @@ -0,0 +1,105 @@ +package com.bugsnag.android.gradle.internal + +import com.bugsnag.android.gradle.Abi +import org.gradle.api.Transformer +import org.gradle.api.internal.provider.DefaultProvider +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito +import java.io.File +import org.mockito.Mockito.`when` as whenMock + +class NdkToolchainTest { + @Test + fun ndk19() { + val toolchain = TestNdkToolchainImpl(File("/19.2.5345600/"), true) + assertEquals(NdkToolchain.MappingTool.OBJDUMP, toolchain.preferredMappingTool()) + } + + @Test + fun ndk21Legacy() { + val toolchain = TestNdkToolchainImpl(File("/21.1.6352462/"), true) + assertEquals(NdkToolchain.MappingTool.OBJDUMP, toolchain.preferredMappingTool()) + } + + @Test + fun ndk21() { + val toolchain = TestNdkToolchainImpl(File("/21.1.6352462/"), false) + assertEquals(NdkToolchain.MappingTool.OBJCOPY, toolchain.preferredMappingTool()) + + val objcopyPath = toolchain.objcopyForAbi(Abi.ARM64_V8A).toString() + assertTrue( + "expected GNU objcopy path, but got: $objcopyPath", + objcopyPath.contains("/aarch64-linux-android-objcopy") + ) + } + + @Test + fun ndk23() { + val toolchain = TestNdkToolchainImpl(File("/23.0.7599858/"), false) + assertEquals(NdkToolchain.MappingTool.OBJCOPY, toolchain.preferredMappingTool()) + + val objcopyPath = toolchain.objcopyForAbi(Abi.ARM64_V8A).toString() + assertTrue( + "expected LLVM objcopy, but got: $objcopyPath", + objcopyPath.contains("/llvm-objcopy") + ) + } + + @Test + fun objcopyOverrides() { + val toolchain = TestNdkToolchainImpl( + File("/23.0.7599858/"), false, mapOf(Abi.ARM64_V8A to "arm64-objcopy") + ) + + assertEquals(NdkToolchain.MappingTool.OBJCOPY, toolchain.preferredMappingTool()) + + assertEquals("arm64-objcopy", toolchain.objcopyForAbi(Abi.ARM64_V8A).toString()) + assertNotEquals("arm64-objcopy", toolchain.objcopyForAbi(Abi.X86).toString()) + assertNotEquals("arm64-objcopy", toolchain.objcopyForAbi(Abi.X86_64).toString()) + assertNotEquals("arm64-objcopy", toolchain.objcopyForAbi(Abi.ARMEABI).toString()) + } + + @Test + fun objdumpOverrides() { + val toolchain = TestNdkToolchainImpl( + File("/19.2.5345600/"), true, mapOf(Abi.ARM64_V8A to "arm64-objcopy") + ) + + assertEquals(NdkToolchain.MappingTool.OBJDUMP, toolchain.preferredMappingTool()) + + assertEquals("arm64-objcopy", toolchain.objdumpForAbi(Abi.ARM64_V8A).toString()) + assertNotEquals("arm64-objcopy", toolchain.objdumpForAbi(Abi.X86).toString()) + assertNotEquals("arm64-objcopy", toolchain.objdumpForAbi(Abi.X86_64).toString()) + assertNotEquals("arm64-objcopy", toolchain.objdumpForAbi(Abi.ARMEABI).toString()) + } +} + +private class TestNdkToolchainImpl( + baseDir: File, + useLegacyNdkSymbolUpload: Boolean, + overrides: Map = emptyMap() +) : NdkToolchain() { + override val baseDir: Property = Mockito.mock(Property::class.java) as Property + override val useLegacyNdkSymbolUpload: Property = Mockito.mock(Property::class.java) as Property + override val overrides: MapProperty = Mockito.mock(MapProperty::class.java) as MapProperty + override val bugsnagNdkVersion: Property = Mockito.mock(Property::class.java) as Property + override val variantName: Property = Mockito.mock(Property::class.java) as Property + + init { + whenMock(this.baseDir.get()).thenReturn(baseDir) + whenMock(this.baseDir.map(any>())) + .thenAnswer { + DefaultProvider { + (it.arguments.first() as Transformer).transform(baseDir) + } + } + whenMock(this.useLegacyNdkSymbolUpload.get()).thenReturn(useLegacyNdkSymbolUpload) + whenMock(this.overrides.get()).thenReturn(overrides) + } +}