From b1412524b81ab3d647068f433096a46da8d22b77 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Jun 2022 13:07:38 +0200 Subject: [PATCH 1/3] feat: Add sentry-android-navigation module (#2116) * feat: Add compose module (navigation) (#2121) * feat: Add compose sample (#2122) --- build.gradle.kts | 1 + buildSrc/src/main/java/Config.kt | 18 +- sentry-android-navigation/.gitignore | 1 + .../api/sentry-android-navigation.api | 15 ++ sentry-android-navigation/build.gradle.kts | 95 ++++++++++ sentry-android-navigation/proguard-rules.pro | 7 + .../src/main/AndroidManifest.xml | 2 + .../navigation/SentryNavigationListener.kt | 64 +++++++ .../SentryNavigationListenerTest.kt | 119 +++++++++++++ sentry-compose/.gitignore | 1 + sentry-compose/api/android/sentry-compose.api | 12 ++ sentry-compose/api/desktop/sentry-compose.api | 0 sentry-compose/build.gradle.kts | 121 +++++++++++++ sentry-compose/gradle.properties | 1 + sentry-compose/proguard-rules.pro | 7 + .../src/androidMain/AndroidManifest.xml | 2 + .../compose/SentryNavigationIntegration.kt | 52 ++++++ .../compose/SentryLifecycleObserverTest.kt | 51 ++++++ .../sentry-samples-android/build.gradle.kts | 14 +- .../src/main/AndroidManifest.xml | 4 + .../sentry/samples/android/GitHubService.kt | 8 +- .../sentry/samples/android/MainActivity.java | 6 + .../android/compose/ComposeActivity.kt | 162 ++++++++++++++++++ .../src/main/res/layout/activity_main.xml | 6 + .../src/main/res/values/strings.xml | 1 + sentry/api/sentry.api | 1 + .../main/java/io/sentry/TypeCheckHint.java | 2 + settings.gradle.kts | 2 + 28 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 sentry-android-navigation/.gitignore create mode 100644 sentry-android-navigation/api/sentry-android-navigation.api create mode 100644 sentry-android-navigation/build.gradle.kts create mode 100644 sentry-android-navigation/proguard-rules.pro create mode 100644 sentry-android-navigation/src/main/AndroidManifest.xml create mode 100644 sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt create mode 100644 sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt create mode 100644 sentry-compose/.gitignore create mode 100644 sentry-compose/api/android/sentry-compose.api create mode 100644 sentry-compose/api/desktop/sentry-compose.api create mode 100644 sentry-compose/build.gradle.kts create mode 100644 sentry-compose/gradle.properties create mode 100644 sentry-compose/proguard-rules.pro create mode 100644 sentry-compose/src/androidMain/AndroidManifest.xml create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryNavigationIntegration.kt create mode 100644 sentry-compose/src/androidTest/kotlin/io/sentry/compose/SentryLifecycleObserverTest.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt diff --git a/build.gradle.kts b/build.gradle.kts index 278b2a94226..401adc72928 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ buildscript { // classpath("io.sentry:sentry-android-gradle-plugin:{version}") classpath(Config.QualityPlugins.binaryCompatibilityValidatorPlugin) + classpath(Config.BuildPlugins.composeGradlePlugin) } } 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/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..dbb4f9b544e --- /dev/null +++ b/sentry-android-navigation/api/sentry-android-navigation.api @@ -0,0 +1,15 @@ +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 fun ()V + public fun (Lio/sentry/IHub;)V + public synthetic fun (Lio/sentry/IHub;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun onDestinationChanged (Landroidx/navigation/NavController;Landroidx/navigation/NavDestination;Landroid/os/Bundle;)V +} + diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts new file mode 100644 index 00000000000..c30519b12bc --- /dev/null +++ b/sentry-android-navigation/build.gradle.kts @@ -0,0 +1,95 @@ +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.extensions.DetektExtension + +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() +} + +configure { + buildUponDefaultConfig = true + allRules = true +} 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..3e4d408858f --- /dev/null +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -0,0 +1,64 @@ +package io.sentry.android.navigation + +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.SentryLevel.INFO +import io.sentry.TypeCheckHint +import java.lang.ref.WeakReference + +class SentryNavigationListener @JvmOverloads constructor( + private val hub: IHub = HubAdapter.getInstance() +) : NavController.OnDestinationChangedListener { + + private var previousDestinationRef: WeakReference? = null + private var previousArgs: Bundle? = null + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + addBreadcrumb(destination, arguments) + previousDestinationRef = WeakReference(destination) + previousArgs = arguments + } + + private fun addBreadcrumb(destination: NavDestination, arguments: Bundle?) { + val breadcrumb = Breadcrumb().apply { + type = "navigation" + category = "navigation" + + val from = previousDestinationRef?.get()?.route + from?.let { data["from"] = it } + previousArgs?.let { args -> + val fromArguments = args.keySet().filter { + it != NavController.KEY_DEEP_LINK_INTENT // there's a lot of unrelated stuff + }.associateWith { args[it] } + if (fromArguments.isNotEmpty()) { + data["from_arguments"] = fromArguments + } + } + + val to = destination.route + to?.let { data["to"] = it } + arguments?.let { args -> + val toArguments = args.keySet().filter { + it != NavController.KEY_DEEP_LINK_INTENT // there's a lot of unrelated stuff + }.associateWith { args[it] } + if (toArguments.isNotEmpty()) { + data["to_arguments"] = toArguments + } + } + + level = INFO + } + val hint = Hint() + hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, destination) + hub.addBreadcrumb(breadcrumb) + } +} 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..8654c7ba4cd --- /dev/null +++ b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt @@ -0,0 +1,119 @@ +package io.sentry.android.navigation + +import androidx.core.os.bundleOf +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.reset +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.SentryLevel +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() + + fun getSut(toRoute: String = "route"): SentryNavigationListener { + whenever(destination.route).thenReturn(toRoute) + return SentryNavigationListener(hub) + } + } + + 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) + } + ) + } + + @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"]) + } + ) + } + + @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"]) + } + ) + } + + @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") + ) + reset(fixture.hub) + + val toDestination = mock { + whenever(mock.route).thenReturn("route_to") + } + sut.onDestinationChanged( + fixture.navController, + toDestination, + bundleOf("to_arg1" to "to_foo") + ) + verify(fixture.hub).addBreadcrumb( + check { + 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"]) + } + ) + } +} 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..2be16fceca2 --- /dev/null +++ b/sentry-compose/build.gradle.kts @@ -0,0 +1,121 @@ +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.extensions.DetektExtension + +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() +} + +configure { + buildUponDefaultConfig = true + allRules = true +} 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-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"/> + +