diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index d888ac3b15..b1d371b4c0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -24,6 +24,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.appnav.analytics.AnalyticsColdStartWatcher import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -43,6 +44,7 @@ class NotLoggedInFlowNode( @Assisted plugins: List, private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, + private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -65,6 +67,7 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() + analyticsColdStartWatcher.whenLoggingIn() lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 6fce57926b..52788fc992 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -29,6 +29,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.AnalyticsColdStartWatcher import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent @@ -89,6 +90,7 @@ class RootFlowNode( private val featureFlagService: FeatureFlagService, private val announcementService: AnnouncementService, private val analyticsService: AnalyticsService, + private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -98,6 +100,7 @@ class RootFlowNode( plugins = plugins ) { override fun onBuilt() { + analyticsColdStartWatcher.start() matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) super.onBuilt() observeNavState() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt new file mode 100644 index 0000000000..29dcd61211 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/analytics/AnalyticsColdStartWatcher.kt @@ -0,0 +1,69 @@ +/* + * 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.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Adds a performance check transaction measuring the time between a cold start (or, after we read the user consent after a cold start) + * until the cached room list is displayed. This check only takes place in a cold app start after the user is authenticated. + */ +interface AnalyticsColdStartWatcher { + fun start() + fun whenLoggingIn() + fun onRoomListVisible() +} + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultAnalyticsColdStartWatcher( + private val analyticsService: AnalyticsService, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : AnalyticsColdStartWatcher { + private val isColdStart = AtomicBoolean(true) + + override fun start() { + analyticsService.userConsentFlow + .onEach { hasConsent -> + if (hasConsent) { + if (isColdStart.get()) { + Timber.d("Starting cold start check") + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + } else { + error("The app is no longer in a cold start state") + } + } + } + .catch { Timber.w(it.message) } + .launchIn(appCoroutineScope) + } + + override fun whenLoggingIn() { + if (isColdStart.getAndSet(false)) { + Timber.d("Canceled cold start check: user is logging in") + } + } + + override fun onRoomListVisible() { + if (isColdStart.getAndSet(false)) { + Timber.d("Room list is visible, finishing cold start check") + analyticsService.removeLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)?.finish() + } + } +} diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index b36ee6aed2..9362702a1a 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -28,6 +28,7 @@ setupDependencyInjection() dependencies { implementation(projects.appconfig) + implementation(projects.appnav) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 6493d76d98..ce1197f9ca 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.Interaction +import io.element.android.appnav.analytics.AnalyticsColdStartWatcher import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource @@ -86,6 +87,7 @@ class RoomListPresenter( private val appPreferencesStore: AppPreferencesStore, private val seenInvitesStore: SeenInvitesStore, private val announcementService: AnnouncementService, + private val coldStartWatcher: AnalyticsColdStartWatcher, ) : Presenter { private val encryptionService = client.encryptionService @@ -236,6 +238,8 @@ class RoomListPresenter( ) showSkeleton -> RoomListContentState.Skeleton(count = 16) else -> { + coldStartWatcher.onRoomListVisible() + RoomListContentState.Rooms( securityBannerState = securityBannerState, showNewNotificationSoundBanner = showNewNotificationSoundBanner, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index cf636e26ca..a3be896f40 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -13,6 +13,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction +import io.element.android.appnav.analytics.AnalyticsColdStartWatcher import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.FakeDateTimeObserver @@ -673,5 +674,14 @@ class RoomListPresenterTest { appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, announcementService = announcementService, + coldStartWatcher = FakeAnalyticsColdStartWatcher(), ) } + +private class FakeAnalyticsColdStartWatcher : AnalyticsColdStartWatcher { + override fun start() {} + + override fun whenLoggingIn() {} + + override fun onRoomListVisible() {} +} 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 96dfe4ba3c..670b9ef268 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 @@ -11,6 +11,7 @@ sealed class AnalyticsLongRunningTransaction( val name: String, val operation: String?, ) { + data object ColdStartUntilCachedRoomList : AnalyticsLongRunningTransaction("Cold start until cached room list is displayed", null) 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) data object NotificationTapOpensTimeline : AnalyticsLongRunningTransaction("A notification was tapped and it opened a timeline", null)