diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 2c48083cdc..db40bf7443 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -42,6 +42,10 @@ gradlePlugin { id = "io.sentry.animalsniffer.android" implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" } + register("sentryRobolectric") { + id = "io.sentry.robolectric" + implementationClass = "io.sentry.gradle.SentryRobolectricPlugin" + } register("sentrySpringBootMatrix") { id = "io.sentry.spring-boot-matrix" implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin" diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryRobolectricPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryRobolectricPlugin.kt new file mode 100644 index 0000000000..a996574f7d --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryRobolectricPlugin.kt @@ -0,0 +1,115 @@ +package io.sentry.gradle + +import com.android.build.api.dsl.LibraryExtension +import java.util.Properties +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.CommandLineArgumentProvider +import org.gradle.work.DisableCachingByDefault + +/** + * The android-all jars our Robolectric tests run against, by API level. Robolectric picks the + * version for each level itself (see its DefaultSdkProvider), so a Robolectric upgrade can change + * them, and a test on a level missing here fails with "no artifacts found for + * org.robolectric:android-all-instrumented:...". + */ +private val ANDROID_ALL_VERSIONS = + mapOf( + 21 to "5.0.2_r3-robolectric-r0", + 24 to "7.0.0_r1-robolectric-r1", + 28 to "9-robolectric-4913185-2", + 29 to "10-robolectric-5803371", + 30 to "11-robolectric-6757853", + 31 to "12-robolectric-7732740", + 33 to "13-robolectric-9030017", + 35 to "15-robolectric-12650502", + ) + +/** The instrumentation revision Robolectric appends to every android-all version. */ +private const val ANDROID_ALL_INSTRUMENTATION = "i7" + +/** + * Has Gradle download the android-all jars that Robolectric runs tests against, instead of + * Robolectric fetching them from Maven Central while the tests run. + * + * Gradle caches them with every other dependency, so CI restores them with the Gradle cache, and + * fetches them before any test JVM starts. See + * https://robolectric.org/blog/2023/11/11/improving-android-all-downloading/. Unlike the post, + * which copies the jars into `robolectric.dependency.dir`, the tests read them straight from + * Gradle's cache through a `robolectric-deps.properties` file, so no module keeps a 1 GB copy. + */ +class SentryRobolectricPlugin : Plugin { + override fun apply(project: Project) { + val coordinatesToJar = + ANDROID_ALL_VERSIONS.values.associate { version -> + val coordinates = + "org.robolectric:android-all-instrumented:$version-$ANDROID_ALL_INSTRUMENTATION" + // One configuration per jar, because resolving them together would keep only the highest + // version. + coordinates to + project.configurations.detachedConfiguration(project.dependencies.create(coordinates)) + } + + val writeDeps = + project.tasks.register("writeRobolectricDeps", WriteRobolectricDeps::class.java) { + coordinatesToJar.forEach { (coordinates, jar) -> + jars.put(coordinates, jar.elements.map { it.single().asFile.absolutePath }) + } + propertiesFile.set(project.layout.buildDirectory.file("robolectric/deps.properties")) + } + + project.plugins.withId("com.android.library") { + project.extensions.getByType(LibraryExtension::class.java).testOptions.unitTests.all { test -> + // The properties file holds absolute paths, so it can't be a test input without breaking + // build cache hits across machines. The jars are the input instead. + test.dependsOn(writeDeps) + test.jvmArgumentProviders.add( + project.objects.newInstance(RobolectricDepsArgument::class.java).apply { + androidAllJars.from(coordinatesToJar.values) + propertiesFile.set(writeDeps.flatMap { it.propertiesFile }) + } + ) + } + } + } +} + +/** + * Writes the file Robolectric's PropertiesDependencyResolver reads: each android-all coordinate + * mapped to its jar. + */ +@DisableCachingByDefault(because = "Writing a few lines is cheaper than caching them") +abstract class WriteRobolectricDeps : DefaultTask() { + @get:Input abstract val jars: MapProperty + + @get:OutputFile abstract val propertiesFile: RegularFileProperty + + @TaskAction + fun write() { + val properties = Properties() + properties.putAll(jars.get()) + propertiesFile.get().asFile.outputStream().use { properties.store(it, null) } + } +} + +abstract class RobolectricDepsArgument : CommandLineArgumentProvider { + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val androidAllJars: ConfigurableFileCollection + + @get:Internal abstract val propertiesFile: RegularFileProperty + + override fun asArguments(): List = + listOf("-Drobolectric-deps.properties=${propertiesFile.get().asFile.absolutePath}") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 590670955b..d6a55ba3d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -306,6 +306,7 @@ okhttp-mockwebserver4 = { module = "com.squareup.okhttp3:mockwebserver", version okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } rhino = { module = "org.mozilla:rhino", version = "1.8.0" } +# Upgrading Robolectric can change the android-all jars it needs, see SentryRobolectricPlugin. roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } [bundles] diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index c261c56f78..c2d01a8aba 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -12,6 +12,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index d4bedf0a02..c6e4bf44cb 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index af9aef5166..9c23e6111e 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -11,6 +11,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index ce2ab209af..6d4f70fc9f 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -12,6 +12,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-android-ndk-outproc/build.gradle.kts b/sentry-android-ndk-outproc/build.gradle.kts index 8e3cce4334..bb8fd5151a 100644 --- a/sentry-android-ndk-outproc/build.gradle.kts +++ b/sentry-android-ndk-outproc/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 8d17ff27f1..034268031b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -13,6 +13,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish") id("io.sentry.api-validation") + id("io.sentry.robolectric") } android { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 6bcd20e2a3..69d4db9386 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.sentry.jdk-target") id("io.sentry.publish.multiplatform") id("io.sentry.api-validation") + id("io.sentry.robolectric") } kotlin {