diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/InstrumentationArgsImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/InstrumentationArgsImpl.kt index f5daf0b3d4..eae0d9a2a8 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/InstrumentationArgsImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/InstrumentationArgsImpl.kt @@ -6,6 +6,7 @@ import io.embrace.android.embracesdk.internal.arch.InstrumentationArgs import io.embrace.android.embracesdk.internal.arch.SessionPartChangeListener import io.embrace.android.embracesdk.internal.arch.SessionPartEndListener import io.embrace.android.embracesdk.internal.arch.datasource.TelemetryDestination +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateTracker import io.embrace.android.embracesdk.internal.capture.session.UserSessionPropertiesService import io.embrace.android.embracesdk.internal.clock.Clock @@ -35,6 +36,7 @@ internal class InstrumentationArgsImpl( override val ordinalStore: OrdinalStore, override val processIdentifier: String, override val appStateTracker: AppStateTracker, + override val navigationTrackingService: NavigationTrackingService, override val telemetryService: TelemetryService, private val workerThreadModule: WorkerThreadModule, private val sessionPartTracker: SessionPartTracker, diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModule.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModule.kt index 43427452ba..c6afa2eddf 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModule.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModule.kt @@ -1,6 +1,7 @@ package io.embrace.android.embracesdk.internal.injection import io.embrace.android.embracesdk.internal.arch.datasource.TelemetryDestination +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateTracker import io.embrace.android.embracesdk.internal.capture.connectivity.NetworkConnectivityService import io.embrace.android.embracesdk.internal.capture.session.UserSessionPropertiesService @@ -13,6 +14,7 @@ import io.embrace.android.embracesdk.internal.session.id.SessionPartTracker */ interface EssentialServiceModule { val appStateTracker: AppStateTracker + val navigationTrackingService: NavigationTrackingService val userService: UserService val networkConnectivityService: NetworkConnectivityService val sessionPartTracker: SessionPartTracker diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModuleImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModuleImpl.kt index e99b2ea404..ad43b7e120 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModuleImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/EssentialServiceModuleImpl.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import io.embrace.android.embracesdk.internal.arch.datasource.TelemetryDestination import io.embrace.android.embracesdk.internal.arch.destination.TelemetryDestinationImpl +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateTracker import io.embrace.android.embracesdk.internal.capture.connectivity.EmbraceNetworkConnectivityService import io.embrace.android.embracesdk.internal.capture.connectivity.NetworkCallbackConnectivityService @@ -15,6 +16,7 @@ import io.embrace.android.embracesdk.internal.capture.session.UserSessionPropert import io.embrace.android.embracesdk.internal.capture.user.EmbraceUserService import io.embrace.android.embracesdk.internal.capture.user.UserService import io.embrace.android.embracesdk.internal.config.ConfigService +import io.embrace.android.embracesdk.internal.navigation.NavigationTrackingServiceImpl import io.embrace.android.embracesdk.internal.session.id.SessionPartTracker import io.embrace.android.embracesdk.internal.session.id.SessionPartTrackerImpl import io.embrace.android.embracesdk.internal.session.lifecycle.AppStateTrackerImpl @@ -39,6 +41,10 @@ class EssentialServiceModuleImpl( } } + override val navigationTrackingService: NavigationTrackingService by singleton { + NavigationTrackingServiceImpl() + } + override val userService: UserService by singleton { EmbTrace.trace("user-service-init") { EmbraceUserService( diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/InstrumentationModuleImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/InstrumentationModuleImpl.kt index 4d112ae6e7..eed0e37760 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/InstrumentationModuleImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/InstrumentationModuleImpl.kt @@ -40,6 +40,7 @@ class InstrumentationModuleImpl( processIdentifier = openTelemetryModule.otelSdkConfig.processIdentifier, crashMarkerFileProvider = { storageService.getFileForWrite("embrace_crash_marker") }, appStateTracker = essentialServiceModule.appStateTracker, + navigationTrackingService = essentialServiceModule.navigationTrackingService, telemetryService = initModule.telemetryService, ) } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/navigation/NavigationTrackingServiceImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/navigation/NavigationTrackingServiceImpl.kt new file mode 100644 index 0000000000..3e1d8aaa90 --- /dev/null +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/navigation/NavigationTrackingServiceImpl.kt @@ -0,0 +1,33 @@ +package io.embrace.android.embracesdk.internal.navigation + +import android.app.Activity +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationControllerEventListener +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingInitListener +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService + +internal class NavigationTrackingServiceImpl( + override var navigationTrackingInitListener: NavigationTrackingInitListener = NoopNavigationTrackingInitListener, + override var navigationControllerEventListener: NavigationControllerEventListener = NoopNavigationControllerEventListener +) : NavigationTrackingService { + + override fun trackNavigation(activity: Activity, controller: Any?) { + navigationTrackingInitListener.trackNavigation(activity, controller) + } + + override fun onControllerAttached(activity: Activity, timestampMs: Long) { + navigationControllerEventListener.onControllerAttached(activity, timestampMs) + } + + override fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) { + navigationControllerEventListener.onDestinationChange(activity, screenName, timestampMs) + } +} + +private object NoopNavigationTrackingInitListener : NavigationTrackingInitListener { + override fun trackNavigation(activity: Activity, controller: Any?) {} +} + +private object NoopNavigationControllerEventListener : NavigationControllerEventListener { + override fun onControllerAttached(activity: Activity, timestampMs: Long) {} + override fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) {} +} diff --git a/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeInstrumentationArgs.kt b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeInstrumentationArgs.kt index dcdb69ea30..46543d5359 100644 --- a/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeInstrumentationArgs.kt +++ b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeInstrumentationArgs.kt @@ -51,6 +51,8 @@ class FakeInstrumentationArgs( override val crashMarkerFile: File by lazy { File.createTempFile("crash_marker", "") } + override val navigationTrackingService = FakeNavigationTrackingService() + override fun registerSessionPartChangeListener(listener: SessionPartChangeListener) { sessionChangeListeners.add(listener) } diff --git a/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationControllerEventListener.kt b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationControllerEventListener.kt new file mode 100644 index 0000000000..3c91d0757a --- /dev/null +++ b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationControllerEventListener.kt @@ -0,0 +1,12 @@ +package io.embrace.android.embracesdk.fakes + +import android.app.Activity +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationControllerEventListener + +class FakeNavigationControllerEventListener: NavigationControllerEventListener { + override fun onControllerAttached(activity: Activity, timestampMs: Long) { + } + + override fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) { + } +} diff --git a/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingInitListener.kt b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingInitListener.kt new file mode 100644 index 0000000000..ccbf275e94 --- /dev/null +++ b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingInitListener.kt @@ -0,0 +1,9 @@ +package io.embrace.android.embracesdk.fakes + +import android.app.Activity +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingInitListener + +class FakeNavigationTrackingInitListener: NavigationTrackingInitListener { + override fun trackNavigation(activity: Activity, controller: Any?) { + } +} diff --git a/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingService.kt b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingService.kt new file mode 100644 index 0000000000..e39b2639aa --- /dev/null +++ b/embrace-android-instrumentation-api-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeNavigationTrackingService.kt @@ -0,0 +1,27 @@ +package io.embrace.android.embracesdk.fakes + +import android.app.Activity +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationControllerEventListener +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingInitListener +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService + +class FakeNavigationTrackingService( + override var navigationTrackingInitListener: NavigationTrackingInitListener = FakeNavigationTrackingInitListener(), + override var navigationControllerEventListener: NavigationControllerEventListener = FakeNavigationControllerEventListener() +) : NavigationTrackingService { + val attachedCalls = mutableListOf() + val destinationChangedCalls = mutableListOf() + + override fun trackNavigation(activity: Activity, controller: Any?) {} + + override fun onControllerAttached(activity: Activity, timestampMs: Long) { + attachedCalls.add(AttachedCall(activity, timestampMs)) + } + + override fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) { + destinationChangedCalls.add(DestinationChangedCall(activity, screenName, timestampMs)) + } + + data class AttachedCall(val activity: Activity, val timestampMs: Long) + data class DestinationChangedCall(val activity: Activity, val screenName: String, val timestampMs: Long) +} diff --git a/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/InstrumentationArgs.kt b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/InstrumentationArgs.kt index 43e590daa1..17cb005604 100644 --- a/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/InstrumentationArgs.kt +++ b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/InstrumentationArgs.kt @@ -3,6 +3,7 @@ package io.embrace.android.embracesdk.internal.arch import android.app.Application import android.content.Context import io.embrace.android.embracesdk.internal.arch.datasource.TelemetryDestination +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateTracker import io.embrace.android.embracesdk.internal.clock.Clock import io.embrace.android.embracesdk.internal.config.ConfigService @@ -115,6 +116,11 @@ interface InstrumentationArgs { */ val crashMarkerFile: File + /** + * Service where navigation controllers and navigation event listeners can be registered + */ + val navigationTrackingService: NavigationTrackingService + /** * Sets a listener that is invoked after a session changes. */ diff --git a/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationControllerEventListener.kt b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationControllerEventListener.kt new file mode 100644 index 0000000000..76ebbbb159 --- /dev/null +++ b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationControllerEventListener.kt @@ -0,0 +1,18 @@ +package io.embrace.android.embracesdk.internal.arch.navigation + +import android.app.Activity + +/** + * Listener that receives events related to components that control navigation + */ +interface NavigationControllerEventListener { + /** + * Called when a component that controls navigation is attached + */ + fun onControllerAttached(activity: Activity, timestampMs: Long) + + /** + * Called when a navigation component attached to the given activity updates its destination + */ + fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) +} diff --git a/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingInitListener.kt b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingInitListener.kt new file mode 100644 index 0000000000..a5aa345a21 --- /dev/null +++ b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingInitListener.kt @@ -0,0 +1,17 @@ +package io.embrace.android.embracesdk.internal.arch.navigation + +import android.app.Activity + +/** + * Receives events related to the initialization of components that control navigation + */ +interface NavigationTrackingInitListener { + /** + * Track navigation events from a controller associated with the given Activity. If the controller is null, the implementation + * will try to find the controller within the given activity. + * + * The implementation is responsible for casting the controller to whatever interface it needs in order to do its tracking, + * as the interface is generic and agnostic to any navigation controller implementation details. + */ + fun trackNavigation(activity: Activity, controller: Any? = null) +} diff --git a/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingService.kt b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingService.kt new file mode 100644 index 0000000000..64cb8c1e20 --- /dev/null +++ b/embrace-android-instrumentation-api/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/navigation/NavigationTrackingService.kt @@ -0,0 +1,16 @@ +package io.embrace.android.embracesdk.internal.arch.navigation + +/** + * Service where navigation controllers can be registered, and when they fire events, they will be dispatched to the listeners. + */ +interface NavigationTrackingService : NavigationTrackingInitListener, NavigationControllerEventListener { + /** + * Register listener that receives events related to the initialization of components that control navigation + */ + var navigationTrackingInitListener: NavigationTrackingInitListener + + /** + * Register listener that receives events related to components that control navigation + */ + var navigationControllerEventListener: NavigationControllerEventListener +} diff --git a/embrace-android-instrumentation-compose-navigation/build.gradle.kts b/embrace-android-instrumentation-compose-navigation/build.gradle.kts index 7c7ee120d8..5e6572c19d 100644 --- a/embrace-android-instrumentation-compose-navigation/build.gradle.kts +++ b/embrace-android-instrumentation-compose-navigation/build.gradle.kts @@ -9,7 +9,11 @@ android { } dependencies { + implementation(project(":embrace-android-instrumentation-api")) implementation(project(":embrace-android-instrumentation-navigation")) implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.common) + testImplementation(project(":embrace-android-instrumentation-api-fakes")) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.navigation.testing) } diff --git a/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/ComposeNavigationInstrumentationProvider.kt b/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/ComposeNavigationInstrumentationProvider.kt new file mode 100644 index 0000000000..2da6a74b2f --- /dev/null +++ b/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/ComposeNavigationInstrumentationProvider.kt @@ -0,0 +1,18 @@ +package io.embrace.android.embracesdk.internal.instrumentation.compose.navigation + +import io.embrace.android.embracesdk.internal.arch.InstrumentationArgs +import io.embrace.android.embracesdk.internal.arch.InstrumentationProvider +import io.embrace.android.embracesdk.internal.arch.datasource.DataSourceState +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService + +/** + * Instrumentation provider that creates and registers [NavControllerTracker] with the [NavigationTrackingService]. + */ +class ComposeNavigationInstrumentationProvider : InstrumentationProvider { + + override fun register(args: InstrumentationArgs): DataSourceState<*>? { + args.navigationTrackingService.navigationTrackingInitListener = + NavControllerTracker(args.navigationTrackingService, args.clock, args.logger) + return null + } +} diff --git a/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTracker.kt b/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTracker.kt new file mode 100644 index 0000000000..cbef8c6daa --- /dev/null +++ b/embrace-android-instrumentation-compose-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTracker.kt @@ -0,0 +1,67 @@ +package io.embrace.android.embracesdk.internal.instrumentation.compose.navigation + +import android.app.Activity +import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.fragment.NavHostFragment +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingInitListener +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService +import io.embrace.android.embracesdk.internal.clock.Clock +import io.embrace.android.embracesdk.internal.logging.InternalErrorType +import io.embrace.android.embracesdk.internal.logging.InternalLogger + +/** + * Discovers and attaches [NavController.OnDestinationChangedListener] instances to Activities that use a [NavController]. + * Implements [NavigationTrackingInitListener] so it can be registered with [NavigationTrackingService] tracking events. + */ +internal class NavControllerTracker( + private val navigationTrackingService: NavigationTrackingService, + private val clock: Clock, + private val logger: InternalLogger, +) : NavigationTrackingInitListener { + + private val trackAttemptStatus = mutableMapOf() + + override fun trackNavigation(activity: Activity, controller: Any?) { + runCatching { + val activityId = System.identityHashCode(activity) + if (trackAttemptStatus[activityId] != true) { + synchronized(trackAttemptStatus) { + if (controller == null && trackAttemptStatus.put(activityId, false) == null) { + findNavController(activity)?.trackForActivity(activity) + } else if (controller is NavController && trackAttemptStatus[activityId] != true) { + controller.trackForActivity(activity) + } + } + } + }.onFailure { + logger.trackInternalError(InternalErrorType.NAV_CONTROLLER_TRACKING_FAIL, it) + } + } + + private fun NavController.trackForActivity(activity: Activity) { + navigationTrackingService.onControllerAttached(activity, clock.now()) + addOnDestinationChangedListener { _, destination, _ -> + navigationTrackingService.onDestinationChange(activity, extractScreenName(destination), clock.now()) + } + trackAttemptStatus[System.identityHashCode(activity)] = true + } + + private fun findNavController(activity: Activity): NavController? { + if (activity is FragmentActivity) { + val navHostFragment = activity.supportFragmentManager.fragments + .firstNotNullOfOrNull { it as? NavHostFragment } + if (navHostFragment != null) { + return navHostFragment.navController + } + } + return null + } + + private fun extractScreenName(destination: NavDestination): String { + return destination.route + ?: destination.label?.toString() + ?: destination.navigatorName + } +} diff --git a/embrace-android-instrumentation-compose-navigation/src/main/resources/META-INF/services/io.embrace.android.embracesdk.internal.arch.InstrumentationProvider b/embrace-android-instrumentation-compose-navigation/src/main/resources/META-INF/services/io.embrace.android.embracesdk.internal.arch.InstrumentationProvider new file mode 100644 index 0000000000..25c927ed6c --- /dev/null +++ b/embrace-android-instrumentation-compose-navigation/src/main/resources/META-INF/services/io.embrace.android.embracesdk.internal.arch.InstrumentationProvider @@ -0,0 +1 @@ +io.embrace.android.embracesdk.internal.instrumentation.compose.navigation.ComposeNavigationInstrumentationProvider diff --git a/embrace-android-instrumentation-compose-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTrackerTest.kt b/embrace-android-instrumentation-compose-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTrackerTest.kt new file mode 100644 index 0000000000..f992e6841e --- /dev/null +++ b/embrace-android-instrumentation-compose-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/compose/navigation/NavControllerTrackerTest.kt @@ -0,0 +1,191 @@ +package io.embrace.android.embracesdk.internal.instrumentation.compose.navigation + +import android.R.id.content +import android.app.Activity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphNavigator +import androidx.navigation.createGraph +import androidx.navigation.fragment.FragmentNavigator +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.fragment +import androidx.navigation.testing.TestNavHostController +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.fakes.FakeClock +import io.embrace.android.embracesdk.fakes.FakeInternalLogger +import io.embrace.android.embracesdk.fakes.FakeNavigationTrackingService +import io.embrace.android.embracesdk.internal.logging.InternalErrorType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity + +@RunWith(AndroidJUnit4::class) +internal class NavControllerTrackerTest { + + private lateinit var clock: FakeClock + private lateinit var fakeTracker: FakeNavigationTrackingService + private lateinit var tracker: NavControllerTracker + private lateinit var logger: FakeInternalLogger + private lateinit var activity: FragmentActivity + + @Before + fun setUp() { + clock = FakeClock() + fakeTracker = FakeNavigationTrackingService() + logger = FakeInternalLogger() + tracker = NavControllerTracker(fakeTracker, clock, logger) + activity = createActivity() + } + + @Test + fun `tracking activity with nav controller produces attached and destination callbacks`() { + tracker.trackNavigation(activity) + assertEquals(1, fakeTracker.attachedCalls.size) + assertEquals(1, fakeTracker.destinationChangedCalls.size) + assertEquals("home", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `track on activity with no nav controller produces no callbacks`() { + tracker.trackNavigation(buildActivity(Activity::class.java).setup().get()) + assertTrue(fakeTracker.attachedCalls.isEmpty()) + } + + @Test + fun `track on FragmentActivity without NavHostFragment produces no callbacks`() { + tracker.trackNavigation(buildActivity(FragmentActivity::class.java).setup().get()) + assertTrue(fakeTracker.attachedCalls.isEmpty()) + } + + @Test + fun `calling track twice on same activity does not produce duplicate callbacks`() { + tracker.trackNavigation(activity) + tracker.trackNavigation(activity) + assertEquals(1, fakeTracker.attachedCalls.size) + } + + @Test + fun `tracking different activity instances produces callbacks for each`() { + val anotherActivity = createActivity() + tracker.trackNavigation(activity) + tracker.trackNavigation(anotherActivity) + assertEquals(2, fakeTracker.attachedCalls.size) + assertEquals(2, fakeTracker.destinationChangedCalls.size) + } + + @Test + fun `destination screen name falls back to label when route is null`() { + val labelActivity = createActivity(::graphWithRoutelessDestinationWithLabel) + tracker.trackNavigation(labelActivity) + assertEquals("My Home", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `destination screen name falls back to navigatorName when route and label are null`() { + val idActivity = createActivity(::graphWithDestinationWithoutRouteAndLabel) + tracker.trackNavigation(idActivity) + assertEquals("fragment", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `tracking NavController explicitly produces callbacks for the provided activity`() { + tracker.trackNavigation(activity, createTestNavController()) + assertEquals(1, fakeTracker.attachedCalls.size) + assertEquals("home", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `activity will always be associated with the first NavController it tracks`() { + tracker.trackNavigation(activity, createTestNavController("foo")) + tracker.trackNavigation(activity, createTestNavController("bar")) + tracker.trackNavigation(activity) + assertEquals(1, fakeTracker.attachedCalls.size) + assertEquals("foo", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `explicit tracking will be ignored if activity has already been discovered`() { + tracker.trackNavigation(activity) + tracker.trackNavigation(activity, createTestNavController("foo")) + assertEquals(1, fakeTracker.attachedCalls.size) + assertEquals("home", fakeTracker.destinationChangedCalls[0].screenName) + } + + @Test + fun `error during tracking is logged and does not crash`() { + val errorLogger = FakeInternalLogger(throwOnInternalError = false) + tracker = NavControllerTracker(fakeTracker, clock, errorLogger) + tracker.trackNavigation(buildActivity(BrokenNavActivity::class.java).setup().get()) + assertTrue(fakeTracker.attachedCalls.isEmpty()) + assertTrue( + errorLogger.internalErrorMessages.any { + it.msg == InternalErrorType.NAV_CONTROLLER_TRACKING_FAIL.toString() + } + ) + } + + private fun createActivity( + navGraphProvider: (navController: NavController) -> NavGraph = { navController -> + navController.createGraph(startDestination = "home") { + fragment("home") + } + }, + ): FragmentActivity { + val activity = buildActivity(FragmentActivity::class.java).setup().get() + val navHostFragment = NavHostFragment() + activity.supportFragmentManager + .beginTransaction() + .add(content, navHostFragment) + .commitNow() + navHostFragment.navController.graph = navGraphProvider(navHostFragment.navController) + return activity + } + + private fun graphWithRoutelessDestinationWithLabel(navController: NavController): NavGraph = + graphWithRoutelessDestination(navController, "My Home") + + private fun graphWithDestinationWithoutRouteAndLabel(navController: NavController): NavGraph = + graphWithRoutelessDestination(navController, null) + + private fun graphWithRoutelessDestination( + navController: NavController, + destinationLabel: CharSequence?, + ): NavGraph { + val fragmentNavigator = navController.navigatorProvider.getNavigator(FragmentNavigator::class.java) + val routelessDestinationId = 1234 + val routelessDestination = fragmentNavigator.createDestination().apply { + id = routelessDestinationId + label = destinationLabel + setClassName(Fragment::class.java.name) + } + return navController.createGraph(startDestination = "ignored") { + fragment("ignored") + }.apply { + nodes.clear() + addDestination(routelessDestination) + setStartDestination(routelessDestinationId) + } + } + + private fun createTestNavController(startDestination: String = "home"): NavController = + TestNavHostController(ApplicationProvider.getApplicationContext()).apply { + val graphNavigator = navigatorProvider.getNavigator(NavGraphNavigator::class.java) + graph = graphNavigator.createDestination().apply { + addDestination(NavDestination("testNavDestination").apply { route = startDestination }) + setStartDestination(startDestination) + } + } + + private class BrokenNavActivity : FragmentActivity() { + override fun getSupportFragmentManager(): androidx.fragment.app.FragmentManager { + throw RuntimeException("Broken fragment manager") + } + } +} diff --git a/embrace-android-instrumentation-navigation/build.gradle.kts b/embrace-android-instrumentation-navigation/build.gradle.kts index 6e7674801f..b115db54a2 100644 --- a/embrace-android-instrumentation-navigation/build.gradle.kts +++ b/embrace-android-instrumentation-navigation/build.gradle.kts @@ -10,10 +10,6 @@ android { dependencies { implementation(project(":embrace-android-instrumentation-api")) - compileOnly(libs.androidx.navigation.fragment) - compileOnly(libs.androidx.navigation.common) testImplementation(project(":embrace-android-instrumentation-api-fakes")) testImplementation(libs.robolectric) - testImplementation(libs.androidx.navigation.fragment) - testImplementation(libs.androidx.navigation.common) } diff --git a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTracker.kt b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTracker.kt index 32ae3d6931..e8de44c765 100644 --- a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTracker.kt +++ b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTracker.kt @@ -4,13 +4,13 @@ import android.app.Activity import android.app.Application import android.os.Build import android.os.Bundle +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateListener import io.embrace.android.embracesdk.internal.clock.Clock import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityPaused import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityResumed import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityStarted import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.Backgrounded -import io.embrace.android.embracesdk.internal.logging.InternalLogger /** * Tracks Activities coming into and out of view through [Application.ActivityLifecycleCallbacks], but listens to [AppStateListener] @@ -21,16 +21,10 @@ import io.embrace.android.embracesdk.internal.logging.InternalLogger internal class ActivityNavigationTracker( private val clock: Clock, private val onEvent: (NavigationEvent) -> Unit, - trackNav: Boolean, - logger: InternalLogger + private val navigationTrackingService: NavigationTrackingService, ) : Application.ActivityLifecycleCallbacks, AppStateListener { private val usePrePostCallbacks = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - private val navControllerTracker: NavControllerTracker? = if (trackNav) { - NavControllerTracker(onEvent, clock, logger) - } else { - null - } override fun onActivityPreStarted(activity: Activity) { if (usePrePostCallbacks) { @@ -79,7 +73,7 @@ internal class ActivityNavigationTracker( override fun onForeground() {} private fun handleActivityStarted(activity: Activity) { - navControllerTracker?.track(activity) + navigationTrackingService.trackNavigation(activity) onEvent(ActivityStarted(activity, clock.now())) } diff --git a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTracker.kt b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTracker.kt deleted file mode 100644 index f8d0263e9d..0000000000 --- a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTracker.kt +++ /dev/null @@ -1,59 +0,0 @@ -package io.embrace.android.embracesdk.internal.instrumentation.navigation - -import android.app.Activity -import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.fragment.NavHostFragment -import io.embrace.android.embracesdk.internal.clock.Clock -import io.embrace.android.embracesdk.internal.logging.InternalErrorType -import io.embrace.android.embracesdk.internal.logging.InternalLogger - -/** - * Discovers and attaches [NavController.OnDestinationChangedListener]s to Activities that use a [NavController] to control navigation. - * - * If an instance is found to not contain a [NavController], it will not be retried. - */ -internal class NavControllerTracker( - private val onEvent: (NavigationEvent) -> Unit, - private val clock: Clock, - private val logger: InternalLogger, -) { - private val processedActivities = mutableSetOf() - - fun track(activity: Activity) { - runCatching { - val processActivity = synchronized(processedActivities) { - processedActivities.add(activity.getId()) - } - - if (processActivity) { - findNavController(activity)?.apply { - onEvent(NavigationEvent.NavControllerAttached(activity, clock.now())) - addOnDestinationChangedListener { _, destination, _ -> - onEvent(NavigationEvent.NavControllerDestinationChanged(activity, extractScreenName(destination), clock.now())) - } - } - } - }.onFailure { - logger.trackInternalError(InternalErrorType.NAV_CONTROLLER_TRACKING_FAIL, it) - } - } - - private fun findNavController(activity: Activity): NavController? { - if (activity is FragmentActivity) { - val navHostFragment = activity.supportFragmentManager.fragments - .firstNotNullOfOrNull { it as? NavHostFragment } - if (navHostFragment != null) { - return navHostFragment.navController - } - } - return null - } - - private fun extractScreenName(destination: NavDestination): String { - return destination.route - ?: destination.label?.toString() - ?: destination.navigatorName - } -} diff --git a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSource.kt b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSource.kt index ff727abe13..d14d94b2ed 100644 --- a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSource.kt +++ b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSource.kt @@ -1,19 +1,24 @@ package io.embrace.android.embracesdk.internal.instrumentation.navigation +import android.app.Activity import io.embrace.android.embracesdk.internal.arch.InstrumentationArgs import io.embrace.android.embracesdk.internal.arch.datasource.StateDataSource +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationControllerEventListener import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState.Screen +/** + * Updates the navigation state by listening to events + */ class NavigationStateDataSource( private val args: InstrumentationArgs, - trackNav: Boolean, ) : StateDataSource( args = args, stateTypeFactory = ::NavigationState, defaultValue = Screen(name = INITIALIZING), maxTransitions = MAX_NAVIGATION_STATE_TRANSITIONS, -) { +), + NavigationControllerEventListener { private val broker = NavigationEventBroker( onScreenLoad = ::onScreenLoad ) @@ -21,12 +26,12 @@ class NavigationStateDataSource( private val activityNavigationTracker = ActivityNavigationTracker( clock = args.clock, onEvent = broker::onEvent, - trackNav = trackNav, - logger = args.logger, + navigationTrackingService = args.navigationTrackingService, ) override fun onDataCaptureEnabled() { super.onDataCaptureEnabled() + args.navigationTrackingService.navigationControllerEventListener = this args.application.registerActivityLifecycleCallbacks(activityNavigationTracker) args.appStateTracker.addListener(activityNavigationTracker) } @@ -35,6 +40,14 @@ class NavigationStateDataSource( args.application.unregisterActivityLifecycleCallbacks(activityNavigationTracker) } + override fun onControllerAttached(activity: Activity, timestampMs: Long) { + broker.onEvent(NavigationEvent.NavControllerAttached(activity, timestampMs)) + } + + override fun onDestinationChange(activity: Activity, screenName: String, timestampMs: Long) { + broker.onEvent(NavigationEvent.NavControllerDestinationChanged(activity, screenName, timestampMs)) + } + fun onScreenLoad(loadTimeMs: Long, screenName: String) { onStateChange(loadTimeMs, Screen(name = screenName)) } diff --git a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateInstrumentationProvider.kt b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateInstrumentationProvider.kt index d0bb660f39..da51baa750 100644 --- a/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateInstrumentationProvider.kt +++ b/embrace-android-instrumentation-navigation/src/main/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateInstrumentationProvider.kt @@ -12,11 +12,6 @@ class NavigationStateInstrumentationProvider : ) { override fun factoryProvider(args: InstrumentationArgs): () -> NavigationStateDataSource { - return { - NavigationStateDataSource( - args = args, - trackNav = runCatching { Class.forName("androidx.navigation.NavController") }.isSuccess - ) - } + return { NavigationStateDataSource(args) } } } diff --git a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTrackerTest.kt b/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTrackerTest.kt index 9007aefd28..644daa75e1 100644 --- a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTrackerTest.kt +++ b/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/ActivityNavigationTrackerTest.kt @@ -2,20 +2,13 @@ package io.embrace.android.embracesdk.internal.instrumentation.navigation import android.app.Activity import android.os.Build -import android.os.Bundle -import androidx.fragment.app.FragmentActivity -import androidx.navigation.createGraph -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.fragment.fragment import androidx.test.ext.junit.runners.AndroidJUnit4 import io.embrace.android.embracesdk.fakes.FakeClock -import io.embrace.android.embracesdk.fakes.FakeInternalLogger +import io.embrace.android.embracesdk.fakes.FakeNavigationTrackingService import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityPaused import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityResumed import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityStarted import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.Backgrounded -import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.NavControllerAttached -import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.NavControllerDestinationChanged import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -32,99 +25,39 @@ internal class ActivityNavigationTrackerTest { private lateinit var events: MutableList private lateinit var activityController: ActivityController private lateinit var anotherController: ActivityController - private lateinit var navActivityController: ActivityController + private lateinit var trackedActivities: MutableList @Before fun setUp() { clock = FakeClock() events = mutableListOf() + trackedActivities = mutableListOf() activityController = Robolectric.buildActivity(DopeActivity::class.java).create() anotherController = Robolectric.buildActivity(CoolActivity::class.java).create() - navActivityController = Robolectric.buildActivity(NavControllerActivity::class.java).create() } @Test @Config(sdk = [Build.VERSION_CODES.P]) - fun `activity with NavController produces nav events before activity events in P`() { - createTracker(true).assertNavControllerActivityOpening(true) + fun `activity transitions produce the right events in P`() { + createTracker().assertPlainActivityNavigation() } @Test @Config(sdk = [Build.VERSION_CODES.Q]) - fun `activity with NavController produces nav events before activity events in Q`() { - createTracker(true).assertNavControllerActivityOpening(true) + fun `activity transitions produce the right events in Q`() { + createTracker().assertPlainActivityNavigation() } @Test @Config(sdk = [Build.VERSION_CODES.P]) - fun `detection disabled produces only activity events even with NavController in P`() { - createTracker(false).assertNavControllerActivityOpening(false) + fun `concurrent activities produce the right events in P`() { + createTracker().assertConcurrentPlainActivityNavigation() } @Test @Config(sdk = [Build.VERSION_CODES.Q]) - fun `detection disabled produces only activity events even with NavController in Q`() { - createTracker(false).assertNavControllerActivityOpening(false) - } - - @Test - @Config(sdk = [Build.VERSION_CODES.P]) - fun `activity transitions without NavController produce the right events in P`() { - createTracker(true).assertPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.Q]) - fun `activity transitions without NavController produce the right events in Q`() { - createTracker(true).assertPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.P]) - fun `concurrent activities without NavController produce the right events in P`() { - createTracker(true).assertConcurrentPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.Q]) - fun `concurrent activities without NavController produce the right events in Q`() { - createTracker(true).assertConcurrentPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.P]) - fun `transition from NavController activity to plain activity in P`() { - createTracker(true).assertNavControllerToPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.Q]) - fun `transition from NavController activity to plain activity in Q`() { - createTracker(true).assertNavControllerToPlainActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.P]) - fun `transition from plain activity to NavController activity in P`() { - createTracker(true).assertPlainToNavControllerActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.Q]) - fun `transition from plain activity to NavController activity in Q`() { - createTracker(true).assertPlainToNavControllerActivityNavigation() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.P]) - fun `NavController activity background and foreground produces no duplicate nav events in P`() { - createTracker(true).assertNavControllerActivityForegrounding() - } - - @Test - @Config(sdk = [Build.VERSION_CODES.Q]) - fun `NavController activity background and foreground produces no duplicate nav events in Q`() { - createTracker(true).assertNavControllerActivityForegrounding() + fun `concurrent activities produce the right events in Q`() { + createTracker().assertConcurrentPlainActivityNavigation() } private fun ActivityNavigationTracker.assertPlainActivityNavigation() { @@ -155,90 +88,12 @@ internal class ActivityNavigationTrackerTest { ) } - private fun ActivityNavigationTracker.assertNavControllerActivityOpening(trackNav: Boolean) { - val times = openActivity(navActivityController) - assertEquals(4, times.size) - val expectedEvents: List = if (trackNav) { - mutableListOf( - NavControllerAttached(navActivityController.get(), times[0]), - NavControllerDestinationChanged(navActivityController.get(), "home", times[0]) - ) - } else { - listOf() - } - - assertEvents( - expectedEvents + listOf( - ActivityStarted(navActivityController.get(), times[0]), - ActivityResumed(navActivityController.get(), times[1]), - ActivityPaused(navActivityController.get(), times[2]), - Backgrounded(times[3]), - ) - ) - } - - private fun ActivityNavigationTracker.assertNavControllerToPlainActivityNavigation() { - val times = transitionBetweenActivities(navActivityController, activityController) - assertEquals(7, times.size) - assertEvents( - NavControllerAttached(navActivityController.get(), times[0]), - NavControllerDestinationChanged(navActivityController.get(), "home", times[0]), - ActivityStarted(navActivityController.get(), times[0]), - ActivityResumed(navActivityController.get(), times[1]), - ActivityPaused(navActivityController.get(), times[2]), - ActivityStarted(activityController.get(), times[3]), - ActivityResumed(activityController.get(), times[4]), - ActivityPaused(activityController.get(), times[5]), - Backgrounded(times[6]), - ) - } - - private fun ActivityNavigationTracker.assertPlainToNavControllerActivityNavigation() { - val times = transitionBetweenActivities(activityController, navActivityController) - assertEquals(7, times.size) - assertEvents( - ActivityStarted(activityController.get(), times[0]), - ActivityResumed(activityController.get(), times[1]), - ActivityPaused(activityController.get(), times[2]), - NavControllerAttached(navActivityController.get(), times[3]), - NavControllerDestinationChanged(navActivityController.get(), "home", times[3]), - ActivityStarted(navActivityController.get(), times[3]), - ActivityResumed(navActivityController.get(), times[4]), - ActivityPaused(navActivityController.get(), times[5]), - Backgrounded(times[6]), - ) - } - - private fun ActivityNavigationTracker.assertNavControllerActivityForegrounding() { - openActivity(navActivityController) - val navControllerEventCount = events.filter { - it is NavControllerAttached || it is NavControllerDestinationChanged - }.size - openActivity(navActivityController) - - // NavController events should not fire again - assertEquals( - navControllerEventCount, - events.filter { - it is NavControllerAttached || it is NavControllerDestinationChanged - }.size - ) - } - private fun ActivityNavigationTracker.transitionBetweenActivities( first: ActivityController, second: ActivityController, ): List { return invokeCallbacks( - listOf( - first::start, - first::resume, - first::pause, - second::start, - second::resume, - second::pause, - ::onBackground - ) + listOf(first::start, first::resume, first::pause, second::start, second::resume, second::pause, ::onBackground) ) } @@ -247,28 +102,7 @@ internal class ActivityNavigationTrackerTest { second: ActivityController, ): List { return invokeCallbacks( - listOf( - first::start, - first::resume, - second::start, - second::resume, - first::pause, - second::pause, - ::onBackground - ) - ) - } - - private fun ActivityNavigationTracker.openActivity( - activity: ActivityController, - ): List { - return invokeCallbacks( - listOf( - activity::start, - activity::resume, - activity::pause, - ::onBackground - ) + listOf(first::start, first::resume, second::start, second::resume, first::pause, second::pause, ::onBackground) ) } @@ -281,31 +115,14 @@ internal class ActivityNavigationTrackerTest { return times } - private fun createTracker(trackNav: Boolean): ActivityNavigationTracker { - return ActivityNavigationTracker(clock, events::add, trackNav, FakeInternalLogger()).apply { + private fun createTracker(): ActivityNavigationTracker { + return ActivityNavigationTracker(clock, events::add, FakeNavigationTrackingService()).apply { RuntimeEnvironment.getApplication().registerActivityLifecycleCallbacks(this) } } private fun assertEvents(vararg expected: NavigationEvent) { - assertEvents(expected.toList()) - } - - private fun assertEvents(expected: List) { - assertEquals(expected, events) - } - - private class NavControllerActivity : FragmentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val navHostFragment = NavHostFragment() - supportFragmentManager.beginTransaction() - .add(android.R.id.content, navHostFragment) - .commitNow() - navHostFragment.navController.graph = navHostFragment.navController.createGraph(startDestination = "home") { - fragment("home") - } - } + assertEquals(expected.toList(), events) } private class DopeActivity : Activity() diff --git a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTrackerTest.kt b/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTrackerTest.kt deleted file mode 100644 index dbfdb675d8..0000000000 --- a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavControllerTrackerTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.embrace.android.embracesdk.internal.instrumentation.navigation - -import android.R.id.content -import android.app.Activity -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavController -import androidx.navigation.NavGraph -import androidx.navigation.createGraph -import androidx.navigation.fragment.FragmentNavigator -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.fragment.fragment -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.embrace.android.embracesdk.fakes.FakeClock -import io.embrace.android.embracesdk.fakes.FakeInternalLogger -import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.NavControllerAttached -import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.NavControllerDestinationChanged -import io.embrace.android.embracesdk.internal.logging.InternalErrorType -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric.buildActivity - -@RunWith(AndroidJUnit4::class) -internal class NavControllerTrackerTest { - - private lateinit var clock: FakeClock - private lateinit var events: MutableList - private lateinit var tracker: NavControllerTracker - private lateinit var logger: FakeInternalLogger - private lateinit var activity: FragmentActivity - - @Before - fun setUp() { - clock = FakeClock() - events = mutableListOf() - logger = FakeInternalLogger() - tracker = NavControllerTracker(events::add, clock, logger) - activity = createActivity() - } - - @Test - fun `tracking activity with nav controller produces attached and destination events`() { - tracker.track(activity) - assertEquals(2, events.size) - assertEquals(NavControllerAttached(activity, clock.now()), events[0]) - assertEquals(NavControllerDestinationChanged(activity, "home", clock.now()), events[1]) - } - - @Test - fun `track on activity with no nav controller produces no events`() { - tracker.track(buildActivity(Activity::class.java).setup().get()) - assertTrue(events.isEmpty()) - } - - @Test - fun `track on FragmentActivity without NavHostFragment produces no events`() { - tracker.track(buildActivity(FragmentActivity::class.java).setup().get()) - assertTrue(events.isEmpty()) - } - - @Test - fun `calling track twice on same activity does not produce duplicate events`() { - tracker.track(activity) - tracker.track(activity) - assertEquals(2, events.size) - } - - @Test - fun `tracking nav controller to different activity instances produces events for each`() { - val anotherActivity = createActivity() - tracker.track(activity) - tracker.track(anotherActivity) - assertEquals(4, events.size) - assertEquals(NavControllerAttached(activity, clock.now()), events[0]) - assertEquals(NavControllerDestinationChanged(activity, "home", clock.now()), events[1]) - assertEquals(NavControllerAttached(anotherActivity, clock.now()), events[2]) - assertEquals(NavControllerDestinationChanged(anotherActivity, "home", clock.now()), events[3]) - } - - @Test - fun `destination change event screen name falls back to label when route is null`() { - val labelActivity = createActivity(::graphWithRoutelessDestinationWithLabel) - tracker.track(labelActivity) - assertEquals(2, events.size) - assertEquals("My Home", (events[1] as NavControllerDestinationChanged).name) - } - - @Test - fun `destination change event screen name falls back to navigatorName when route and label are null`() { - val idActivity = createActivity(::graphWithDestinationWithoutRouteAndLabel) - tracker.track(idActivity) - assertEquals(2, events.size) - assertEquals("fragment", (events[1] as NavControllerDestinationChanged).name) - } - - @Test - fun `error during tracking is logged and does not crash`() { - val errorLogger = FakeInternalLogger(throwOnInternalError = false) - tracker = NavControllerTracker(events::add, clock, errorLogger) - tracker.track(buildActivity(BrokenNavActivity::class.java).setup().get()) - assertTrue(events.isEmpty()) - assertTrue( - errorLogger.internalErrorMessages.any { - it.msg == InternalErrorType.NAV_CONTROLLER_TRACKING_FAIL.toString() - } - ) - } - - private fun createActivity( - navGraphProvider: (navController: NavController) -> NavGraph = { navController: NavController -> - navController.createGraph(startDestination = "home") { - fragment("home") - } - }, - ): FragmentActivity { - val activity = buildActivity(FragmentActivity::class.java).setup().get() - val navHostFragment = NavHostFragment() - activity.supportFragmentManager - .beginTransaction() - .add(content, navHostFragment) - .commitNow() - val navController = navHostFragment.navController - navController.graph = navGraphProvider(navController) - return activity - } - - private fun graphWithRoutelessDestinationWithLabel(navController: NavController): NavGraph = - graphWithRoutelessDestination(navController, "My Home") - - private fun graphWithDestinationWithoutRouteAndLabel(navController: NavController): NavGraph = - graphWithRoutelessDestination(navController, null) - - private fun graphWithRoutelessDestination( - navController: NavController, - destinationLabel: CharSequence?, - ): NavGraph { - val fragmentNavigator = navController.navigatorProvider.getNavigator(FragmentNavigator::class.java) - val routelessDestinationId = 1234 - val routelessDestination = fragmentNavigator.createDestination().apply { - id = routelessDestinationId - label = destinationLabel - setClassName(Fragment::class.java.name) - } - val graph = navController.createGraph(startDestination = "ignored") { - fragment("ignored") - }.apply { - nodes.clear() - addDestination(routelessDestination) - setStartDestination(routelessDestinationId) - } - return graph - } - - private class BrokenNavActivity : FragmentActivity() { - override fun getSupportFragmentManager(): androidx.fragment.app.FragmentManager { - throw RuntimeException("Broken fragment manager") - } - } -} diff --git a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSourceTest.kt b/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSourceTest.kt index 55fdaac330..a305455555 100644 --- a/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSourceTest.kt +++ b/embrace-android-instrumentation-navigation/src/test/kotlin/io/embrace/android/embracesdk/internal/instrumentation/navigation/NavigationStateDataSourceTest.kt @@ -17,7 +17,7 @@ internal class NavigationStateDataSourceTest { @Before fun setUp() { args = FakeInstrumentationArgs(ApplicationProvider.getApplicationContext()) - dataSource = NavigationStateDataSource(args, true) + dataSource = NavigationStateDataSource(args) } @Test diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeEssentialServiceModule.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeEssentialServiceModule.kt index 535dd812c4..36cc64b3d5 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeEssentialServiceModule.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeEssentialServiceModule.kt @@ -1,12 +1,14 @@ package io.embrace.android.embracesdk.fakes.injection import io.embrace.android.embracesdk.fakes.FakeAppStateTracker +import io.embrace.android.embracesdk.fakes.FakeNavigationTrackingService import io.embrace.android.embracesdk.fakes.FakeNetworkConnectivityService -import io.embrace.android.embracesdk.fakes.FakeUserSessionPropertiesService import io.embrace.android.embracesdk.fakes.FakeSessionPartTracker import io.embrace.android.embracesdk.fakes.FakeTelemetryDestination import io.embrace.android.embracesdk.fakes.FakeUserService +import io.embrace.android.embracesdk.fakes.FakeUserSessionPropertiesService import io.embrace.android.embracesdk.internal.arch.datasource.TelemetryDestination +import io.embrace.android.embracesdk.internal.arch.navigation.NavigationTrackingService import io.embrace.android.embracesdk.internal.arch.state.AppStateTracker import io.embrace.android.embracesdk.internal.capture.connectivity.NetworkConnectivityService import io.embrace.android.embracesdk.internal.capture.user.UserService @@ -15,6 +17,7 @@ import io.embrace.android.embracesdk.internal.session.id.SessionPartTracker class FakeEssentialServiceModule( override val appStateTracker: AppStateTracker = FakeAppStateTracker(), + override val navigationTrackingService: NavigationTrackingService = FakeNavigationTrackingService(), override val sessionPartTracker: SessionPartTracker = FakeSessionPartTracker(), override val userService: UserService = FakeUserService(), override val networkConnectivityService: NetworkConnectivityService = FakeNetworkConnectivityService(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 07af9a6e05..6cc7d678bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } androidx-navigation-common = { module = "androidx.navigation:navigation-common-ktx", version.ref = "navigation" } +androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigation" } asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asmUtil" } gradle-test-kit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = "gradleTestKit" } zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstdJni" }