diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 5d0f373c11..310913e0ac 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -41,6 +41,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.annotations.ContributesNode +import io.element.android.appnav.analytics.AnalyticsRoomListStateWatcher import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.MediaPreviewConfigMigration import io.element.android.appnav.loggedin.SendQueues @@ -139,6 +140,7 @@ class LoggedInFlowNode( private val buildMeta: BuildMeta, snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, + private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -202,6 +204,7 @@ class LoggedInFlowNode( } lifecycle.subscribe( onCreate = { + analyticsRoomListStateWatcher.start() appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) @@ -238,6 +241,7 @@ class LoggedInFlowNode( appNavigationStateService.onLeavingSession(id) loggedInFlowProcessor.stopObserving() matrixClient.sessionVerificationService.setListener(null) + analyticsRoomListStateWatcher.stop() } ) setupSendingQueue() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcher.kt b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcher.kt new file mode 100644 index 0000000000..91efa0bf92 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcher.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.analytics + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.withPreviousValue +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +/** + * This component is used to check how long it takes for the room list to be up to date after opening the app while it's on a 'warm' state: + * the app was previously running and we just returned to it. + */ +@Inject +class AnalyticsRoomListStateWatcher( + private val appNavigationStateService: AppNavigationStateService, + private val roomListService: RoomListService, + private val analyticsService: AnalyticsService, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) { + private var currentCoroutineScope: CoroutineScope? = null + private val isWarmState = AtomicBoolean(false) + + fun start() { + if (currentCoroutineScope != null) { + Timber.w("Can't start RoomListStateWatcher, it's already running.") + return + } + + val coroutineScope = CoroutineScope(sessionCoroutineScope.coroutineContext + dispatchers.computation) + appNavigationStateService.appNavigationState + .map { it.isInForeground } + .distinctUntilChanged() + .withPreviousValue() + .onEach { (wasInForeground, isInForeground) -> + if (isInForeground && roomListService.state.value != RoomListService.State.Running) { + analyticsService.startLongRunningTransaction(ResumeAppUntilNewRoomsReceived) + } + + if (wasInForeground == false && isInForeground) { + isWarmState.set(true) + } + } + .launchIn(coroutineScope) + + roomListService.state + .onEach { state -> + if (state == RoomListService.State.Running && isWarmState.get()) { + analyticsService.removeLongRunningTransaction(ResumeAppUntilNewRoomsReceived)?.finish() + } + } + .launchIn(coroutineScope) + + currentCoroutineScope = coroutineScope + } + + fun stop() { + currentCoroutineScope?.cancel() + currentCoroutineScope = null + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcherTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcherTest.kt new file mode 100644 index 0000000000..f05190acc7 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcherTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.analytics + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AnalyticsRoomListStateWatcherTest { + @Test + fun `Opening the app in a warm state tracks the time until the room list is synced`() = runTest { + val navigationStateService = FakeAppNavigationStateService() + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Idle) + } + val analyticsService = FakeAnalyticsService() + val watcher = createAnalyticsRoomListStateWatcher( + appNavigationStateService = navigationStateService, + roomListService = roomListService, + analyticsService = analyticsService, + ) + + watcher.start() + + // Give some time to load the initial state + runCurrent() + + // Make sure it's warm by changing its internal state + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + runCurrent() + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + runCurrent() + + // The transaction should be present now + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + + // And now the room list service running + roomListService.postState(RoomListService.State.Running) + runCurrent() + + // And the transaction should now be gone + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + + watcher.stop() + } + + @Test + fun `Opening the app in a cold state does nothing`() = runTest { + val navigationStateService = FakeAppNavigationStateService().apply { + appNavigationState.emit(AppNavigationState(NavigationState.Root, false)) + } + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Idle) + } + val analyticsService = FakeAnalyticsService() + val watcher = createAnalyticsRoomListStateWatcher( + appNavigationStateService = navigationStateService, + roomListService = roomListService, + analyticsService = analyticsService, + ) + + watcher.start() + + // Give some time to load the initial state + runCurrent() + + // The room list service running + roomListService.postState(RoomListService.State.Running) + runCurrent() + + // The transaction was never present + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + + watcher.stop() + } + + @Test + fun `The transaction won't be finished until the room list is synchronised`() = runTest { + val navigationStateService = FakeAppNavigationStateService() + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Idle) + } + val analyticsService = FakeAnalyticsService() + val watcher = createAnalyticsRoomListStateWatcher( + appNavigationStateService = navigationStateService, + roomListService = roomListService, + analyticsService = analyticsService, + ) + + watcher.start() + + // Give some time to load the initial state + runCurrent() + + // Make sure it's warm by changing its internal state + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + runCurrent() + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + runCurrent() + + // The transaction should be present now + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + + runCurrent() + + // But without the room list syncing, it never finishes + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + + watcher.stop() + } + + @Test + fun `Opening the app when the room list state was already Running does nothing`() = runTest { + val navigationStateService = FakeAppNavigationStateService() + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Running) + } + val analyticsService = FakeAnalyticsService() + val watcher = createAnalyticsRoomListStateWatcher( + appNavigationStateService = navigationStateService, + roomListService = roomListService, + analyticsService = analyticsService, + ) + + watcher.start() + + // Give some time to load the initial state + runCurrent() + + // Make sure it's warm by changing its internal state + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + runCurrent() + navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + runCurrent() + + // The transaction was never added + assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + + watcher.stop() + } + + private fun TestScope.createAnalyticsRoomListStateWatcher( + appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(), + roomListService: FakeRoomListService = FakeRoomListService(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + ) = AnalyticsRoomListStateWatcher( + appNavigationStateService = appNavigationStateService, + roomListService = roomListService, + analyticsService = analyticsService, + sessionCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt index e465e2450a..c4aa5cc69b 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt @@ -12,4 +12,5 @@ sealed class AnalyticsLongRunningTransaction( val operation: String?, ) { data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null) + data object ResumeAppUntilNewRoomsReceived : AnalyticsLongRunningTransaction("App was resumed and new room list items arrived", null) }