From 1b868e73c73ceb1a3800a0b0700f6f2650a38f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 19 Nov 2025 17:27:56 +0100 Subject: [PATCH] Add a 'warm' room list performance check: We want to measure how long it takes the SDK to update the room list when the app comes back from being in background. Note we don't want to check this in cold starts, only warm ones. --- .../android/appnav/LoggedInFlowNode.kt | 4 + .../AnalyticsRoomListStateWatcher.kt | 79 ++++++++ .../AnalyticsRoomListStateWatcherTest.kt | 170 ++++++++++++++++++ .../api/AnalyticsLongRunningTransaction.kt | 1 + 4 files changed, 254 insertions(+) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcher.kt create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/analytics/AnalyticsRoomListStateWatcherTest.kt 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) }