diff --git a/.craft.yml b/.craft.yml index 763f85252d5..7b791bc5671 100644 --- a/.craft.yml +++ b/.craft.yml @@ -41,3 +41,5 @@ targets: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: maven:io.sentry:sentry-graphql: + maven:io.sentry:sentry-android-navigation: + maven:io.sentry:sentry-compose: diff --git a/CHANGELOG.md b/CHANGELOG.md index 98408682bbc..bfee768fa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Features + +- New package `sentry-android-navigation` for AndroidX Navigation support ([#2136](https://github.com/getsentry/sentry-java/pull/2136)) +- New package `sentry-compose` for Jetpack Compose support (Navigation) ([#2136](https://github.com/getsentry/sentry-java/pull/2136)) + ## 6.1.4 ### Fixes diff --git a/build.gradle.kts b/build.gradle.kts index 278b2a94226..b2d5d2a1e04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import com.diffplug.spotless.LineEnding import com.vanniktech.maven.publish.MavenPublishPlugin import com.vanniktech.maven.publish.MavenPublishPluginExtension +import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent @@ -34,6 +35,7 @@ buildscript { // classpath("io.sentry:sentry-android-gradle-plugin:{version}") classpath(Config.QualityPlugins.binaryCompatibilityValidatorPlugin) + classpath(Config.BuildPlugins.composeGradlePlugin) } } @@ -88,6 +90,14 @@ allprojects { } subprojects { + plugins.withId(Config.QualityPlugins.detektPlugin) { + configure { + buildUponDefaultConfig = true + allRules = true + config.setFrom("${rootProject.rootDir}/detekt.yml") + } + } + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { apply() diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index c4f305dda46..2be2eda1a72 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,12 +1,14 @@ import java.math.BigDecimal object Config { - val kotlinVersion = "1.5.31" + val kotlinVersion = "1.6.10" val kotlinStdLib = "stdlib-jdk8" val springBootVersion = "2.6.8" val kotlinCompatibleLanguageVersion = "1.4" + val composeVersion = "1.1.1" + object BuildPlugins { val androidGradle = "com.android.tools.build:gradle:7.2.0" val kotlinGradlePlugin = "gradle-plugin" @@ -19,14 +21,16 @@ object Config { val grettyVersion = "4.0.0" val gradleMavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.18.0" val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:$kotlinVersion" + val composeGradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$composeVersion" } object Android { - private val sdkVersion = 31 + private val sdkVersion = 32 val minSdkVersion = 14 val minSdkVersionOkHttp = 21 val minSdkVersionNdk = 16 + val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion val compileSdkVersion = sdkVersion @@ -104,6 +108,16 @@ object Config { val graphQlJava = "com.graphql-java:graphql-java:17.3" val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" + val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib" + + private val navigationVersion = "2.4.2" + val navigationRuntime = "androidx.navigation:navigation-runtime:$navigationVersion" + // compose deps + val composeNavigation = "androidx.navigation:navigation-compose:$navigationVersion" + val composeActivity = "androidx.activity:activity-compose:1.4.0" + val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" + val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" + val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" } object AnnotationProcessors { diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000000..49f91ae64fb --- /dev/null +++ b/detekt.yml @@ -0,0 +1,3 @@ +style: + ReturnCount: + max: 3 diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index a0bab5910ee..1cd48226c04 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension plugins { id("com.android.library") @@ -79,8 +78,3 @@ tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } - -configure { - buildUponDefaultConfig = true - allRules = true -} diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index ed39396aa51..3393321f528 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import net.ltgt.gradle.errorprone.errorprone plugins { @@ -122,11 +121,6 @@ tasks.withType { jvmTarget = JavaVersion.VERSION_1_8.toString() } -configure { - buildUponDefaultConfig = true - allRules = true -} - kotlin { explicitApi() } diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index f1cea81b1c6..60b95b1c703 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import net.ltgt.gradle.errorprone.errorprone plugins { @@ -120,11 +119,6 @@ tasks.withType { jvmTarget = JavaVersion.VERSION_1_8.toString() } -configure { - buildUponDefaultConfig = true - allRules = true -} - kotlin { explicitApi() } diff --git a/sentry-android-navigation/.gitignore b/sentry-android-navigation/.gitignore new file mode 100644 index 00000000000..796b96d1c40 --- /dev/null +++ b/sentry-android-navigation/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sentry-android-navigation/api/sentry-android-navigation.api b/sentry-android-navigation/api/sentry-android-navigation.api new file mode 100644 index 00000000000..1ae144d5ddf --- /dev/null +++ b/sentry-android-navigation/api/sentry-android-navigation.api @@ -0,0 +1,22 @@ +public final class io/sentry/android/navigation/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/navigation/SentryNavigationListener : androidx/navigation/NavController$OnDestinationChangedListener { + public static final field Companion Lio/sentry/android/navigation/SentryNavigationListener$Companion; + public static final field NAVIGATION_OP Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/IHub;)V + public fun (Lio/sentry/IHub;Z)V + public fun (Lio/sentry/IHub;ZZ)V + public synthetic fun (Lio/sentry/IHub;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun onDestinationChanged (Landroidx/navigation/NavController;Landroidx/navigation/NavDestination;Landroid/os/Bundle;)V +} + +public final class io/sentry/android/navigation/SentryNavigationListener$Companion { +} + diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts new file mode 100644 index 00000000000..52501ef4188 --- /dev/null +++ b/sentry-android-navigation/build.gradle.kts @@ -0,0 +1,89 @@ +import io.gitlab.arturbosch.detekt.Detekt + +plugins { + id("com.android.library") + kotlin("android") + jacoco + id(Config.QualityPlugins.gradleVersions) + id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersion + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("debug") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +tasks.withType { + configure { + isIncludeNoLocationClasses = false + } +} + +kotlin { + explicitApi() +} + +dependencies { + api(projects.sentry) + + compileOnly(Config.Libs.navigationRuntime) + + // tests + testImplementation(Config.Libs.navigationRuntime) + + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + + testImplementation(Config.TestLibs.robolectric) + testImplementation(Config.TestLibs.androidxCore) + testImplementation(Config.TestLibs.androidxRunner) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.androidxCoreKtx) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-navigation/proguard-rules.pro b/sentry-android-navigation/proguard-rules.pro new file mode 100644 index 00000000000..244282115a5 --- /dev/null +++ b/sentry-android-navigation/proguard-rules.pro @@ -0,0 +1,7 @@ +##---------------Begin: proguard configuration for Compose ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for Compose ---------- diff --git a/sentry-android-navigation/src/main/AndroidManifest.xml b/sentry-android-navigation/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..fcb1e82cca4 --- /dev/null +++ b/sentry-android-navigation/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt new file mode 100644 index 00000000000..d2a82f7b43b --- /dev/null +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -0,0 +1,151 @@ +package io.sentry.android.navigation + +import android.content.res.Resources.NotFoundException +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.HubAdapter +import io.sentry.IHub +import io.sentry.ITransaction +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SpanStatus +import io.sentry.TypeCheckHint +import java.lang.ref.WeakReference + +class SentryNavigationListener @JvmOverloads constructor( + private val hub: IHub = HubAdapter.getInstance(), + private val enableNavigationBreadcrumbs: Boolean = true, + private val enableNavigationTracing: Boolean = true +) : NavController.OnDestinationChangedListener { + + private var previousDestinationRef: WeakReference? = null + private var previousArgs: Bundle? = null + + private val isPerformanceEnabled get() = hub.options.isTracingEnabled && enableNavigationTracing + + private var activeTransaction: ITransaction? = null + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + val toArguments = arguments.refined() + + addBreadcrumb(destination, toArguments) + startTracing(controller, destination, toArguments) + previousDestinationRef = WeakReference(destination) + previousArgs = arguments + } + + private fun addBreadcrumb(destination: NavDestination, arguments: Map) { + if (!enableNavigationBreadcrumbs) { + return + } + val breadcrumb = Breadcrumb().apply { + type = NAVIGATION_OP + category = NAVIGATION_OP + + val from = previousDestinationRef?.get()?.route + from?.let { data["from"] = "/$it" } + val fromArguments = previousArgs.refined() + if (fromArguments.isNotEmpty()) { + data["from_arguments"] = fromArguments + } + + val to = destination.route + to?.let { data["to"] = "/$it" } + if (arguments.isNotEmpty()) { + data["to_arguments"] = arguments + } + + level = INFO + } + val hint = Hint() + hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, destination) + hub.addBreadcrumb(breadcrumb, hint) + } + + private fun startTracing( + controller: NavController, + destination: NavDestination, + arguments: Map + ) { + if (!isPerformanceEnabled) { + return + } + + // we can only have one nav transaction at a time + if (activeTransaction != null) { + stopTracing() + } + + if (destination.navigatorName == "activity") { + // we do not trace navigation between activities to avoid clashing with activity lifecycle tracing + hub.options.logger.log( + DEBUG, + "Navigating to activity destination, no transaction captured." + ) + return + } + + @Suppress("SwallowedException") // we swallow it on purpose + var name = destination.route ?: try { + controller.context.resources.getResourceEntryName(destination.id) + } catch (e: NotFoundException) { + hub.options.logger.log( + DEBUG, + "Destination id cannot be retrieved from Resources, no transaction captured." + ) + return + } + + // we add '/' to the name to match dart and web pattern + name = "/" + name.substringBefore('/') // strip out arguments from the tx name + + val transaction = + hub.startTransaction(name, NAVIGATION_OP, true, hub.options.idleTimeout, true) + + if (arguments.isNotEmpty()) { + transaction.setData("arguments", arguments) + } + hub.configureScope { scope -> + scope.withTransaction { tx -> + if (tx == null) { + scope.transaction = transaction + } + } + } + activeTransaction = transaction + } + + private fun stopTracing() { + val status = activeTransaction?.status ?: SpanStatus.OK + activeTransaction?.finish(status) + + // clear transaction from scope so others can bind to it + hub.configureScope { scope -> + scope.withTransaction { tx -> + if (tx == activeTransaction) { + scope.clearTransaction() + } + } + } + + activeTransaction = null + } + + private fun Bundle?.refined(): Map = + this?.let { args -> + args.keySet().filter { + it != NavController.KEY_DEEP_LINK_INTENT // there's a lot of unrelated stuff + }.associateWith { args[it] } + } ?: emptyMap() + + companion object { + const val NAVIGATION_OP = "navigation" + } +} diff --git a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt new file mode 100644 index 00000000000..72d9a298c7a --- /dev/null +++ b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt @@ -0,0 +1,346 @@ +package io.sentry.android.navigation + +import android.content.Context +import android.content.res.Resources +import androidx.core.os.bundleOf +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.Scope.IWithTransaction +import io.sentry.ScopeCallback +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +class SentryNavigationListenerTest { + + class Fixture { + val hub = mock() + val destination = mock() + val navController = mock() + + val context = mock() + val resources = mock() + val scope = mock() + + lateinit var transaction: SentryTracer + + @Suppress("LongParameterList") + fun getSut( + toRoute: String? = "route", + toId: String? = "destination-id-1", + enableBreadcrumbs: Boolean = true, + enableTracing: Boolean = true, + tracesSampleRate: Double? = 1.0, + hasViewIdInRes: Boolean = true, + transaction: SentryTracer = SentryTracer( + TransactionContext( + "/$toRoute", + SentryNavigationListener.NAVIGATION_OP + ), + hub + ) + ): SentryNavigationListener { + this.transaction = transaction + + whenever(hub.startTransaction(any(), any(), any(), anyOrNull(), any())) + .thenReturn(transaction) + whenever(hub.options).thenReturn( + SentryOptions().apply { + setTracesSampleRate( + tracesSampleRate + ) + } + ) + whenever(hub.configureScope(any())).thenAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + } + + whenever(destination.id).thenReturn(1) + if (hasViewIdInRes) { + whenever(resources.getResourceEntryName(1)).thenReturn(toId) + } else { + whenever(resources.getResourceEntryName(destination.id)).thenThrow( + Resources.NotFoundException() + ) + } + whenever(context.resources).thenReturn(resources) + whenever(navController.context).thenReturn(context) + whenever(destination.route).thenReturn(toRoute) + return SentryNavigationListener(hub, enableBreadcrumbs, enableTracing) + } + } + + private val fixture = Fixture() + + @Test + fun `onDestinationChanged captures a breadcrumb`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("navigation", it.type) + assertEquals("navigation", it.category) + assertEquals("/route", it.data["to"]) + assertEquals(SentryLevel.INFO, it.level) + }, + any() + ) + } + + @Test + fun `onDestinationChanged captures a breadcrumb with arguments`() { + val sut = fixture.getSut() + + sut.onDestinationChanged( + fixture.navController, + fixture.destination, + bundleOf("arg1" to "foo", "arg2" to "bar") + ) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("/route", it.data["to"]) + assertEquals(mapOf("arg1" to "foo", "arg2" to "bar"), it.data["to_arguments"]) + }, + any() + ) + } + + @Test + fun `onDestinationChanged does not send empty args map`() { + val sut = fixture.getSut() + + sut.onDestinationChanged( + fixture.navController, + fixture.destination, + bundleOf() + ) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("/route", it.data["to"]) + assertNull(it.data["to_arguments"]) + }, + any() + ) + } + + @Test + fun `onDestinationChanged captures a breadcrumb with from and to destinations`() { + val sut = fixture.getSut(toRoute = "route_from") + + sut.onDestinationChanged( + fixture.navController, + fixture.destination, + bundleOf("from_arg1" to "from_foo") + ) + + val toDestination = mock { + whenever(mock.route).thenReturn("route_to") + } + sut.onDestinationChanged( + fixture.navController, + toDestination, + bundleOf("to_arg1" to "to_foo") + ) + val captor = argumentCaptor() + verify(fixture.hub, times(2)).addBreadcrumb(captor.capture(), any()) + captor.secondValue.let { + assertEquals("/route_from", it.data["from"]) + assertEquals(mapOf("from_arg1" to "from_foo"), it.data["from_arguments"]) + + assertEquals("/route_to", it.data["to"]) + assertEquals(mapOf("to_arg1" to "to_foo"), it.data["to_arguments"]) + } + } + + @Test + fun `onDestinationChanged does not capture a breadcrumb when breadcrumbs are disabled`() { + val sut = fixture.getSut(enableBreadcrumbs = false) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + @Test + fun `onDestinationChanged does not start tracing when tracing is disabled`() { + val sut = fixture.getSut(enableTracing = false) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub, never()).startTransaction( + any(), + any(), + any(), + anyOrNull(), + any() + ) + } + + @Test + fun `onDestinationChanged does not start tracing when tracesSampleRate is not set`() { + val sut = fixture.getSut(enableTracing = true, tracesSampleRate = null) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub, never()).startTransaction( + any(), + any(), + any(), + anyOrNull(), + any() + ) + } + + @Test + fun `onDestinationChanged does not start tracing when navigating between activities`() { + val sut = fixture.getSut() + whenever(fixture.destination.navigatorName).thenReturn("activity") + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub, never()).startTransaction( + any(), + any(), + any(), + anyOrNull(), + any() + ) + } + + @Test + fun `onDestinationChanged does not start tracing when route and id are not available`() { + val sut = fixture.getSut(toRoute = null, hasViewIdInRes = false) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub, never()).startTransaction( + any(), + any(), + any(), + anyOrNull(), + any() + ) + } + + @Test + fun `onDestinationChanged starts tracing with the route name as transaction name`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub).startTransaction( + check { assertEquals("/route", it) }, + check { assertEquals(SentryNavigationListener.NAVIGATION_OP, it) }, + any(), anyOrNull(), any() + ) + } + + @Test + fun `onDestinationChanged strips out route parameters from transaction name`() { + val sut = fixture.getSut(toRoute = "github/{user_id}?per_page={per_page}") + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub).startTransaction( + check { assertEquals("/github", it) }, + any(), any(), anyOrNull(), any() + ) + } + + @Test + fun `onDestinationChanged starts tracing with destination id if route is not available`() { + val sut = fixture.getSut(toRoute = null, hasViewIdInRes = true) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.hub).startTransaction( + check { assertEquals("/destination-id-1", it) }, + any(), any(), anyOrNull(), any() + ) + } + + @Test + fun `onDestinationChanged captures arguments as additional data for transaction`() { + val sut = fixture.getSut(toRoute = "github/{user_id}?per_page={per_page}") + + sut.onDestinationChanged( + fixture.navController, + fixture.destination, + bundleOf("user_id" to 123, "per_page" to 10) + ) + + verify(fixture.hub).startTransaction( + check { assertEquals("/github", it) }, + any(), any(), anyOrNull(), any() + ) + + val capturedArgs = fixture.transaction.data!!["arguments"] + require(capturedArgs is Map<*, *>) + assertEquals(123, capturedArgs["user_id"]) + assertEquals(10, capturedArgs["per_page"]) + } + + @Test + fun `onDestinationChanged binds transaction to the Scope`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + val captor = argumentCaptor() + verify(fixture.scope).withTransaction(captor.capture()) + captor.firstValue.accept(null) + verify(fixture.scope).transaction = fixture.transaction + } + + @Test + fun `onDestinationChanged does not replace existing transaction on the Scope`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + val captor = argumentCaptor() + verify(fixture.scope).withTransaction(captor.capture()) + captor.firstValue.accept(mock()) + verify(fixture.scope, never()).transaction = fixture.transaction + } + + @Test + fun `onDestinationChanged finishes previous navigation transaction before starting a new one`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + assertEquals(true, fixture.transaction.isFinished) + val captor = argumentCaptor() + verify(fixture.scope, times(4)).withTransaction(captor.capture()) + // 1st time - bind to scope, 2nd time - in SentryTracer when finish, 3rd time - in the nav listener + captor.thirdValue.accept(fixture.transaction) + verify(fixture.scope).clearTransaction() + } +} diff --git a/sentry-android-okhttp/build.gradle.kts b/sentry-android-okhttp/build.gradle.kts index 53243225084..65a29cce96c 100644 --- a/sentry-android-okhttp/build.gradle.kts +++ b/sentry-android-okhttp/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { @@ -86,8 +85,3 @@ tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } - -configure { - buildUponDefaultConfig = true - allRules = true -} diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 16649360d2b..5d193aecb43 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { @@ -87,8 +86,3 @@ tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } - -configure { - buildUponDefaultConfig = true - allRules = true -} diff --git a/sentry-compose/.gitignore b/sentry-compose/.gitignore new file mode 100644 index 00000000000..796b96d1c40 --- /dev/null +++ b/sentry-compose/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api new file mode 100644 index 00000000000..39dac2eda40 --- /dev/null +++ b/sentry-compose/api/android/sentry-compose.api @@ -0,0 +1,12 @@ +public final class io/sentry/compose/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/compose/SentryNavigationIntegrationKt { + public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;Landroidx/compose/runtime/Composer;I)Landroidx/navigation/NavHostController; +} + diff --git a/sentry-compose/api/desktop/sentry-compose.api b/sentry-compose/api/desktop/sentry-compose.api new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts new file mode 100644 index 00000000000..baf80736669 --- /dev/null +++ b/sentry-compose/build.gradle.kts @@ -0,0 +1,115 @@ +import io.gitlab.arturbosch.detekt.Detekt + +plugins { + kotlin("multiplatform") + id("com.android.library") + id("org.jetbrains.compose") + jacoco + id(Config.QualityPlugins.gradleVersions) + id(Config.QualityPlugins.detektPlugin) + `maven-publish` // necessary for publishMavenLocal task to publish correct artifacts +} + +kotlin { + explicitApi() + + android { + publishLibraryVariants("release") + } + jvm("desktop") { + compilations.all { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + } + } + + sourceSets.all { + // Allow all experimental APIs, since MPP projects are themselves experimental + languageSettings.apply { + optIn("kotlin.Experimental") + optIn("kotlin.ExperimentalMultiplatform") + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(compose.runtime) + api(compose.ui) + + implementation(Config.Libs.kotlinStdLib) + } + } + val androidMain by getting { + dependencies { + api(projects.sentry) + api(projects.sentryAndroidNavigation) + + api(Config.Libs.composeNavigation) + implementation(Config.Libs.lifecycleCommonJava8) + } + } + val androidTest by getting { + dependencies { + implementation(Config.TestLibs.kotlinTestJunit) + implementation(Config.TestLibs.mockitoKotlin) + implementation(Config.TestLibs.mockitoInline) + } + } + } +} + +android { + compileSdk = Config.Android.compileSdkVersion + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersionCompose + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + sourceSets["main"].apply { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + } + + buildTypes { + getByName("debug") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +tasks.withType { + configure { + isIncludeNoLocationClasses = false + } +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-compose/gradle.properties b/sentry-compose/gradle.properties new file mode 100644 index 00000000000..2dcadc5b955 --- /dev/null +++ b/sentry-compose/gradle.properties @@ -0,0 +1 @@ +kotlin.mpp.stability.nowarn=true diff --git a/sentry-compose/proguard-rules.pro b/sentry-compose/proguard-rules.pro new file mode 100644 index 00000000000..244282115a5 --- /dev/null +++ b/sentry-compose/proguard-rules.pro @@ -0,0 +1,7 @@ +##---------------Begin: proguard configuration for Compose ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for Compose ---------- diff --git a/sentry-compose/src/androidMain/AndroidManifest.xml b/sentry-compose/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000000..ae7d1a148fe --- /dev/null +++ b/sentry-compose/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryNavigationIntegration.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryNavigationIntegration.kt new file mode 100644 index 00000000000..d73341fb119 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryNavigationIntegration.kt @@ -0,0 +1,52 @@ +package io.sentry.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import io.sentry.HubAdapter +import io.sentry.IHub +import io.sentry.android.navigation.SentryNavigationListener + +internal class SentryLifecycleObserver( + private val navController: NavController, + private val hub: IHub = HubAdapter.getInstance(), + private val navListener: NavController.OnDestinationChangedListener = + SentryNavigationListener(hub) +) : LifecycleEventObserver { + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_RESUME) { + navController.addOnDestinationChangedListener(navListener) + } else if (event == Lifecycle.Event.ON_PAUSE) { + navController.removeOnDestinationChangedListener(navListener) + } + } + + fun dispose() { + navController.removeOnDestinationChangedListener(navListener) + } +} + +// As described in https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects#6 +@Composable +@NonRestartableComposable +public fun NavHostController.withSentryObservableEffect(): NavHostController { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, this) { + val observer = SentryLifecycleObserver(this@withSentryObservableEffect) + + lifecycle.addObserver(observer) + + onDispose { + observer.dispose() + lifecycle.removeObserver(observer) + } + } + return this +} diff --git a/sentry-compose/src/androidTest/kotlin/io/sentry/compose/SentryLifecycleObserverTest.kt b/sentry-compose/src/androidTest/kotlin/io/sentry/compose/SentryLifecycleObserverTest.kt new file mode 100644 index 00000000000..6af44680a8c --- /dev/null +++ b/sentry-compose/src/androidTest/kotlin/io/sentry/compose/SentryLifecycleObserverTest.kt @@ -0,0 +1,51 @@ +package io.sentry.compose + +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavController +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import io.sentry.IHub +import io.sentry.android.navigation.SentryNavigationListener +import kotlin.test.Test + +internal class SentryLifecycleObserverTest { + + class Fixture { + val navListener = mock() + val hub = mock() + val navController = mock() + + fun getSut(): SentryLifecycleObserver { + return SentryLifecycleObserver(navController, hub, navListener) + } + } + + private val fixture = Fixture() + + @Test + fun `onResume adds navigation listener`() { + val sut = fixture.getSut() + + sut.onStateChanged(mock(), Lifecycle.Event.ON_RESUME) + + verify(fixture.navController).addOnDestinationChangedListener(fixture.navListener) + } + + @Test + fun `onPause removes navigation listener`() { + val sut = fixture.getSut() + + sut.onStateChanged(mock(), Lifecycle.Event.ON_PAUSE) + + verify(fixture.navController).removeOnDestinationChangedListener(fixture.navListener) + } + + @Test + fun `dispose removes navigation listener`() { + val sut = fixture.getSut() + + sut.dispose() + + verify(fixture.navController).removeOnDestinationChangedListener(fixture.navListener) + } +} diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 27bd5273ed9..fe10716085e 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -1,5 +1,4 @@ import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -71,11 +70,6 @@ tasks.withType { jvmTarget = JavaVersion.VERSION_1_8.toString() } -configure { - buildUponDefaultConfig = true - allRules = true -} - kotlin { explicitApi() } diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 33b50109097..9a99dff982c 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -8,7 +8,7 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = Config.Android.minSdkVersionOkHttp + minSdk = Config.Android.minSdkVersionCompose targetSdk = Config.Android.targetSdkVersion versionCode = 2 versionName = "1.1.0" @@ -36,6 +36,11 @@ android { // Determines whether to support View Binding. // Note that the viewBinding.enabled property is now deprecated. viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.composeVersion } dependenciesInfo { @@ -104,6 +109,7 @@ dependencies { implementation(projects.sentryAndroidOkhttp) implementation(projects.sentryAndroidFragment) implementation(projects.sentryAndroidTimber) + implementation(projects.sentryCompose) implementation(Config.Libs.fragment) // how to exclude androidx if release health feature is disabled @@ -118,5 +124,11 @@ dependencies { implementation(Config.Libs.retrofit2) implementation(Config.Libs.retrofit2Gson) + implementation(Config.Libs.composeActivity) + implementation(Config.Libs.composeFoundation) + implementation(Config.Libs.composeFoundationLayout) + implementation(Config.Libs.composeNavigation) + implementation(Config.Libs.composeMaterial) + debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index ab6ece24c2b..3c742226de7 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -53,6 +53,10 @@ android:name=".PermissionsActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt index ff725b3a751..36b6a5a4958 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GitHubService.kt @@ -3,11 +3,17 @@ package io.sentry.samples.android import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Query interface GitHubService { @GET("users/{user}/repos") fun listRepos(@Path("user") user: String): Call> + + @GET("users/{user}/repos") + suspend fun listReposAsync(@Path("user") user: String, @Query("per_page") perPage: Int): List } -class Repo +class Repo { + val full_name: String = "" +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 5379ec4faaa..cdd434faa33 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -10,6 +10,7 @@ import io.sentry.UserFeedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.samples.android.compose.ComposeActivity; import io.sentry.samples.android.databinding.ActivityMainBinding; import java.io.BufferedWriter; import java.io.File; @@ -183,6 +184,11 @@ protected void onCreate(Bundle savedInstanceState) { startActivity(new Intent(this, PermissionsActivity.class)); }); + binding.openComposeActivity.setOnClickListener( + view -> { + startActivity(new Intent(this, ComposeActivity.class)); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt new file mode 100644 index 00000000000..74f7cdccfc0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -0,0 +1,162 @@ +package io.sentry.samples.android.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import io.sentry.Sentry +import io.sentry.compose.withSentryObservableEffect +import io.sentry.samples.android.GithubAPI +import kotlinx.coroutines.launch + +class ComposeActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val navController = rememberNavController().withSentryObservableEffect() + SampleNavigation(navController) + } + } + + override fun onResume() { + super.onResume() + Sentry.getSpan()?.finish() + } +} + +@Composable +fun Landing( + navigateGithub: () -> Unit, + navigateGithubWithArgs: () -> Unit +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Button( + onClick = { navigateGithub() }, + modifier = Modifier.padding(top = 32.dp) + ) { + Text("Navigate to Github Page") + } + Button( + onClick = { navigateGithubWithArgs() }, + modifier = Modifier.padding(top = 32.dp) + ) { + Text("Navigate to Github Page With Args") + } + Button( + onClick = { throw RuntimeException("Crash from Compose") }, + modifier = Modifier.padding(top = 32.dp) + ) { + Text("Crash from Compose") + } + } +} + +@Composable +fun Github( + user: String = "getsentry", + perPage: Int = 30 +) { + var user by remember { mutableStateOf(TextFieldValue(user)) } + var result by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + LaunchedEffect(perPage) { + result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + TextField( + value = user, + onValueChange = { newText -> + user = newText + } + ) + Text("Random repo $result") + Button( + onClick = { + scope.launch { + result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } + }, + modifier = Modifier.padding(top = 32.dp) + ) { + Text("Make Request") + } + } +} + +@Composable +fun SampleNavigation(navController: NavHostController) { + NavHost( + navController = navController, + startDestination = Destination.Landing.route + ) { + composable(Destination.Landing.route) { + Landing( + navigateGithub = { navController.navigate("github") }, + navigateGithubWithArgs = { navController.navigate("github/spotify?per_page=10") } + ) + } + composable(Destination.Github.route) { + Github() + } + composable( + Destination.GithubWithArgs.route, + arguments = listOf( + navArgument(Destination.USER_ARG) { type = NavType.StringType }, + navArgument(Destination.PER_PAGE_ARG) { type = NavType.IntType; defaultValue = 10 } + ) + ) { + Github( + it.arguments?.getString(Destination.USER_ARG) ?: "getsentry", + it.arguments?.getInt(Destination.PER_PAGE_ARG) ?: 10 + ) + } + } +} + +sealed class Destination( + val route: String +) { + object Landing : Destination("landing") + object Github : Destination("github") + object GithubWithArgs : Destination("github/{$USER_ARG}?$PER_PAGE_ARG={$PER_PAGE_ARG}") + + companion object { + const val USER_ARG = "user" + const val PER_PAGE_ARG = "per_page" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 3f0549fdf8a..ee75e72299e 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -111,6 +111,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/open_permissions_activity"/> + +