From cc75f96406fba41fc4b0e9cd5331e8fabdb54ae4 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:02:53 +0200 Subject: [PATCH] feat(startup): prepare the main app session --- .../di/metro/AppSessionViewModelGraph.kt | 21 +- .../com/wire/android/ui/WireActivity.kt | 190 ++++++++++---- .../wire/android/ui/WireActivityViewModel.kt | 238 +++++++++++++----- .../ui/SessionGraphStoreViewModelTest.kt | 35 +-- .../android/ui/WireActivityViewModelTest.kt | 133 +++++++--- 5 files changed, 446 insertions(+), 171 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt index 1cccbc689f..e5888b4116 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt @@ -27,6 +27,7 @@ import com.wire.android.ui.home.HomeViewModelGraph import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.GraphExtension @@ -48,6 +49,7 @@ interface AppSessionViewModelGraph : CommonViewModelGraph { @get:CurrentAccount val currentAccount: UserId + val userSessionScope: UserSessionScope override val viewModelScopeKey: String get() = currentAccount.toString() @@ -60,10 +62,23 @@ interface AppSessionViewModelGraph : @ContributesTo(AppScope::class) @GraphExtension.Factory interface Factory { - fun createAppSessionViewModelGraph(@Provides @CurrentAccount currentAccount: UserId): AppSessionViewModelGraph + fun createAppSessionViewModelGraph( + @Provides @CurrentAccount currentAccount: UserId, + @Provides userSessionScope: UserSessionScope, + ): AppSessionViewModelGraph } } -fun WireApplicationGraph.createSessionViewModelGraph(currentAccount: UserId): AppSessionViewModelGraph { - return asContribution().createAppSessionViewModelGraph(currentAccount) +fun WireApplicationGraph.createSessionViewModelGraph( + currentAccount: UserId, + userSessionScope: UserSessionScope, +): AppSessionViewModelGraph { + return asContribution().createAppSessionViewModelGraph(currentAccount, userSessionScope) } + +/** + * Compatibility path for secondary activities while their preparation entry points are migrated + * in the next stack slice. + */ +fun WireApplicationGraph.createSessionViewModelGraph(currentAccount: UserId): AppSessionViewModelGraph = + createSessionViewModelGraph(currentAccount, coreLogic.getSessionScope(currentAccount)) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 0815c26fa1..ee7a5b4bdc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -20,6 +20,7 @@ package com.wire.android.ui import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.SystemClock @@ -164,6 +165,8 @@ import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.ShakeDetector +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.SwitchAccountObserver import com.wire.android.util.SyncStateObserver import com.wire.android.util.debug.FeatureVisibilityFlags @@ -171,8 +174,11 @@ import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.getProviderAuthority import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -228,9 +234,15 @@ class WireActivity : BaseActivity() { private val newIntents = Channel(Channel.UNLIMITED) private lateinit var shakeDetector: ShakeDetector - // This flag is used to keep the splash screen open until the first screen is drawn. + // The system splash only covers the short handoff to Compose. User-session preparation has + // its own app screen because a SQLDelight migration can take much longer. private var shouldKeepSplashOpen = true private var isAppLockActivityLaunching = false + private var startupJob: Job? = null + private var isShowingPreparationContent = false + private val migrationScreenVisibility = MigrationScreenVisibility { + SystemClock.elapsedRealtime() + } override fun onCreate(savedInstanceState: Bundle?) { val startupAt = SystemClock.elapsedRealtime() @@ -248,55 +260,10 @@ class WireActivity : BaseActivity() { enableEdgeToEdge() setupOrientationForDevice() shakeDetector = ShakeDetector(this) - - lifecycleScope.launch { - traceStartup("activity.startupCoroutine.begin", startupAt) - - traceStartup("activity.observePersistentConnectionStatus.start", startupAt) - viewModel.observePersistentConnectionStatus() - - traceStartup("activity.initialAppState.start", startupAt) - val initialAppState = viewModel.initialAppState() - val startDestination = when (initialAppState) { - InitialAppState.NotLoggedIn -> when (loginTypeSelector.canUseNewLogin()) { - true -> NewWelcomeEmptyStartScreenDestination() - false -> WelcomeScreenDestination() - } - - is InitialAppState.EnrollE2EI -> E2EIEnrollmentScreenDestination( - SessionBackedAuthenticationNavArgs.from(initialAppState.userId) - ) - - InitialAppState.LoggedIn -> HomeScreenDestination() - } - traceStartup("activity.initialAppState.resolved:$startDestination", startupAt) - setComposableContent(startDestination) - traceStartup("activity.setContent.done", startupAt) - - // When the app is locked, get the app lock screen up before the splash screen is - // dismissed so that protected content never flashes. Waiting for the current user id - // is finite here because a locked app implies a logged-in session; when not logged in - // there is nothing to protect. Locks that happen after startup are handled by the - // lifecycle observer below. - if (initialAppState != InitialAppState.NotLoggedIn && lockCodeTimeManager.value.isAppLocked()) { - observeAppLockUserId( - isAppLocked = lockCodeTimeManager.value.observeAppLock(), - currentUserId = snapshotFlow { viewModel.globalAppState.currentUserId }, - ).first().let { currentUserId -> - startAppLockActivity(currentUserId = currentUserId) - } - traceStartup("activity.appLock.launched", startupAt) - } - - traceStartup("activity.splash.hide", startupAt) - shouldKeepSplashOpen = false - traceStartup("activity.splash.dismissed", startupAt) - (application as? WireApplication)?.initializeDeferredLoggingAfterSplash() - traceStartup("activity.deferredLogging.triggered", startupAt) - - handleNewIntent(initialQueuedIntent) - traceStartup("activity.initialIntent.dispatched", startupAt) - } + setUserSessionPreparationContent(initialQueuedIntent, startupAt) + traceStartup("activity.preparationContent.done", startupAt) + observePreparationScreenReveal() + startForegroundStartup(initialQueuedIntent, startupAt) lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { @@ -370,11 +337,101 @@ class WireActivity : BaseActivity() { } private fun setComposableContent(startDestination: Direction) { + isShowingPreparationContent = false setContent { WireActivityRoot(startDestination) } } + private fun setUserSessionPreparationContent(initialQueuedIntent: QueuedIntent, startupAt: Long) { + isShowingPreparationContent = true + setContent { + WireTheme(accent = viewModel.globalAppState.userAccent) { + UserSessionPreparationScreen( + state = viewModel.userSessionPreparationState, + onRetry = { startForegroundStartup(initialQueuedIntent, startupAt) }, + onUpdate = ::updateTheApp, + onContactSupport = ::openSupport, + ) + } + } + } + + /** + * Reveals the preparation screen only for work that outlives the reveal delay. + * + * [collectLatest] cancels the pending delay as soon as preparation moves on, so a migration that + * finishes quickly never leaves the splash and the screen is skipped altogether. + */ + private fun observePreparationScreenReveal() { + lifecycleScope.launch { + snapshotFlow { viewModel.userSessionPreparationState } + .collectLatest { state -> + val revealDelay = state.preparationScreenRevealDelay() ?: return@collectLatest + delay(revealDelay) + if (isShowingPreparationContent && state == UserSessionPreparationUiState.MigratingDatabase) { + migrationScreenVisibility.onRevealed() + } + releaseSystemSplash() + } + } + } + + private fun releaseSystemSplash() { + if (!shouldKeepSplashOpen) return + shouldKeepSplashOpen = false + (application as? WireApplication)?.initializeDeferredLoggingAfterSplash() + } + + private fun startForegroundStartup(initialQueuedIntent: QueuedIntent, startupAt: Long) { + if (startupJob?.isActive == true) return + startupJob = lifecycleScope.launch { + traceStartup("activity.startupCoroutine.begin", startupAt) + traceStartup("activity.initialAppState.start", startupAt) + val initialAppState = viewModel.initialAppState() + if (initialAppState == InitialAppState.SessionPreparationFailed) { + traceStartup("activity.initialAppState.preparationFailed", startupAt) + releaseSystemSplash() + return@launch + } + val startDestination = when (initialAppState) { + InitialAppState.NotLoggedIn -> when (loginTypeSelector.canUseNewLogin()) { + true -> NewWelcomeEmptyStartScreenDestination() + false -> WelcomeScreenDestination() + } + + is InitialAppState.EnrollE2EI -> E2EIEnrollmentScreenDestination( + SessionBackedAuthenticationNavArgs.from(initialAppState.userId) + ) + + InitialAppState.LoggedIn -> HomeScreenDestination() + InitialAppState.SessionPreparationFailed -> error("Handled above") + } + traceStartup("activity.initialAppState.resolved:$startDestination", startupAt) + // No-op unless the migration screen was actually revealed, so fast startups pay nothing. + delay(migrationScreenVisibility.remainingVisibility()) + setComposableContent(startDestination) + releaseSystemSplash() + traceStartup("activity.setContent.done", startupAt) + + traceStartup("activity.observePersistentConnectionStatus.start", startupAt) + viewModel.observePersistentConnectionStatus() + + if (initialAppState != InitialAppState.NotLoggedIn && lockCodeTimeManager.value.isAppLocked()) { + observeAppLockUserId( + isAppLocked = lockCodeTimeManager.value.observeAppLock(), + currentUserId = snapshotFlow { viewModel.globalAppState.currentUserId }, + ).first().let { currentUserId -> + startAppLockActivity(currentUserId = currentUserId) + } + traceStartup("activity.appLock.launched", startupAt) + } + + handleNewIntent(initialQueuedIntent) + traceStartup("activity.initialIntent.dispatched", startupAt) + } + } + private fun traceStartup(event: String, startedAt: Long? = null) { val elapsed = startedAt?.let { " (+${SystemClock.elapsedRealtime() - it}ms)" }.orEmpty() Log.i(TAG, "startup:$event$elapsed") @@ -421,6 +478,7 @@ class WireActivity : BaseActivity() { } @Composable + @Suppress("CyclomaticComplexMethod") private fun WireActivityThemedContent( startDestination: Direction, appGraph: WireApplicationGraph, @@ -428,6 +486,15 @@ class WireActivity : BaseActivity() { sessionGraphStore: SessionGraphStoreViewModel, context: Context, ) { + if (viewModel.userSessionPreparationState !is UserSessionPreparationUiState.Ready) { + UserSessionPreparationScreen( + state = viewModel.userSessionPreparationState, + onRetry = viewModel::retryPendingUserSessionPreparation, + onUpdate = ::updateTheApp, + onContactSupport = ::openSupport, + ) + return + } val isUserUiBlocked = viewModel.globalAppState.blockUserUI != null val navigator = rememberWireActivityNavigator( isUserUiBlocked = isUserUiBlocked, @@ -474,6 +541,8 @@ class WireActivity : BaseActivity() { startDestinationBaseRoute = navHostStartDestination.baseRoute, isUserUiBlocked = isUserUiBlocked, isSessionTransitionInProgress = isSessionTransitionInProgress, + preparedSessionScope = sessionBackedAuthenticationUserId?.let(viewModel::preparedUserSessionScope) + ?: currentUserId?.let(viewModel::preparedUserSessionScope), ) val lastSessionGraphContext = remember { mutableStateOf(null) } if (graphContext?.sessionGraph != null) { @@ -666,6 +735,7 @@ class WireActivity : BaseActivity() { startDestinationBaseRoute: String, isUserUiBlocked: Boolean, isSessionTransitionInProgress: Boolean, + preparedSessionScope: UserSessionScope?, ): WireActivityGraphContext? { if (isUserUiBlocked) return null @@ -681,6 +751,7 @@ class WireActivity : BaseActivity() { usesNoSessionAuthenticationGraph, usesInvalidSessionBackedAuthenticationGraph, isSessionTransitionInProgress, + preparedSessionScope, ) { sessionGraphStore.resolveSessionGraph( currentUserId = currentUserId, @@ -688,6 +759,7 @@ class WireActivity : BaseActivity() { usesNoSessionAuthenticationGraph = usesNoSessionAuthenticationGraph, usesInvalidSessionBackedAuthenticationGraph = usesInvalidSessionBackedAuthenticationGraph, isSessionTransitionInProgress = isSessionTransitionInProgress, + preparedSessionScope = preparedSessionScope, ) } val sessionGraph = retainedSessionGraph?.graph @@ -717,18 +789,21 @@ class WireActivity : BaseActivity() { } } + @Suppress("LongParameterList") private fun SessionGraphStoreViewModel.resolveSessionGraph( currentUserId: UserId?, sessionBackedAuthenticationUserId: UserId?, usesNoSessionAuthenticationGraph: Boolean, usesInvalidSessionBackedAuthenticationGraph: Boolean, isSessionTransitionInProgress: Boolean, + preparedSessionScope: UserSessionScope?, ): RetainedSessionGraph? = when { usesNoSessionAuthenticationGraph -> null usesInvalidSessionBackedAuthenticationGraph -> null isSessionTransitionInProgress -> null - sessionBackedAuthenticationUserId != null -> retainedGraphFor(sessionBackedAuthenticationUserId) - currentUserId != null -> retainedGraphFor(currentUserId) + sessionBackedAuthenticationUserId != null && preparedSessionScope != null -> + retainedGraphFor(sessionBackedAuthenticationUserId, preparedSessionScope) + currentUserId != null && preparedSessionScope != null -> retainedGraphFor(currentUserId, preparedSessionScope) else -> null } @@ -1191,6 +1266,11 @@ class WireActivity : BaseActivity() { private fun updateTheApp() = this.launchUpdateTheApp() + private fun openSupport() { + val supportUrl = SupportUrlResolver.resolve(resources, SupportPage.SUPPORT) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(supportUrl))) + } + override fun onResume() { super.onResume() shakeDetector.start() @@ -1376,16 +1456,16 @@ private data class WireActivityGraphContext( ) internal class SessionGraphStoreViewModel( - private val createSessionGraph: (UserId) -> AppSessionViewModelGraph, + private val createSessionGraph: (UserId, UserSessionScope) -> AppSessionViewModelGraph, ) : ViewModel() { private val sessionGraphs = mutableMapOf() private var activeUserId: UserId? = null - fun retainedGraphFor(userId: UserId): RetainedSessionGraph { + fun retainedGraphFor(userId: UserId, userSessionScope: UserSessionScope): RetainedSessionGraph { activeUserId = userId return sessionGraphs.getOrPut(userId) { appLogger.i("WireActivity creating lifecycle-retained session graph for $userId") - RetainedSessionGraph(createSessionGraph(userId)) + RetainedSessionGraph(createSessionGraph(userId, userSessionScope)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index d908c79c2d..d0793c5f3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -32,10 +32,6 @@ import com.wire.android.config.NomadProfilesFeatureConfig import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.IsProfileQRCodeEnabledUseCaseProvider import com.wire.android.di.KaliumCoreLogic -import com.wire.android.di.ObserveIfE2EIRequiredDuringLoginUseCaseProvider -import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider -import com.wire.android.di.ObserveSelfUserUseCaseProvider -import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountActions @@ -43,6 +39,8 @@ import com.wire.android.feature.SwitchAccountParam import com.wire.android.feature.SwitchAccountResult import com.wire.android.navigation.LoginTypeSelector import com.wire.android.services.ServicesManager +import com.wire.android.session.AppUserSessionPreparationResult +import com.wire.android.session.UserSessionPreparationGate import com.wire.android.sync.MonitorSyncWorkUseCase import com.wire.android.ui.authentication.devices.model.displayName import com.wire.android.ui.common.ActionsViewModel @@ -77,6 +75,7 @@ import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.appVersioning.ObserveIfAppUpdateRequiredUseCase +import com.wire.kalium.logic.feature.UserSessionScope import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.client.ClearNewClientsForUserUseCase @@ -99,6 +98,7 @@ import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotC import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import kotlinx.datetime.Instant import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -106,20 +106,25 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.InputStream import java.io.InputStreamReader +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import dev.zacsweers.metro.Inject private const val AUTOMATED_NOMAD_COOKIE_LABEL = "shared-device" @@ -137,19 +142,13 @@ class WireActivityViewModel @Inject constructor( private val observeSessions: Lazy, private val accountSwitch: Lazy, private val servicesManager: Lazy, - private val observeSyncStateUseCaseProviderFactory: ObserveSyncStateUseCaseProvider.Factory, private val observeIfAppUpdateRequired: Lazy, private val observeNewClients: Lazy, private val clearNewClientsForUser: Lazy, private val currentScreenManager: Lazy, - private val observeScreenshotCensoringConfigUseCaseProviderFactory: - ObserveScreenshotCensoringConfigUseCaseProvider.Factory, private val globalDataStore: Lazy, - private val observeIfE2EIRequiredDuringLoginUseCaseProviderFactory: - ObserveIfE2EIRequiredDuringLoginUseCaseProvider.Factory, private val workManager: Lazy, private val isProfileQRCodeEnabledFactory: IsProfileQRCodeEnabledUseCaseProvider.Factory, - private val observeSelfUserFactory: ObserveSelfUserUseCaseProvider.Factory, private val monitorSyncWorkUseCase: MonitorSyncWorkUseCase, private val managedConfigurationsManager: ManagedConfigurationsManager, private val defaultServerConfig: ServerConfig.Links, @@ -162,6 +161,16 @@ class WireActivityViewModel @Inject constructor( var globalAppState: GlobalAppState by mutableStateOf(GlobalAppState()) private set + internal var userSessionPreparationState: UserSessionPreparationUiState by + mutableStateOf(UserSessionPreparationUiState.ResolvingSession) + private set + + private val userSessionPreparationGate by lazy { UserSessionPreparationGate(coreLogic.value) } + private val preparedSessionScopes = ConcurrentHashMap() + private val preparedCurrentSession = MutableStateFlow(null) + private val sessionObserversStarted = AtomicBoolean(false) + private var pendingPreparationUserId: UserId? = null + private val _observeSyncFlowState: MutableStateFlow = MutableStateFlow(null) val observeSyncFlowState: StateFlow = _observeSyncFlowState @@ -171,14 +180,6 @@ class WireActivityViewModel @Inject constructor( .flowOn(dispatchers.io()) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - private val observeCurrentValidUserId: SharedFlow = observeCurrentAccountInfo - .map { - if (it?.isValid() == true) it.userId else null - } - .distinctUntilChanged() - .flowOn(dispatchers.io()) - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - private val observeCurrentUserId: SharedFlow = observeCurrentAccountInfo .map { if (it?.isValid() == true) it.userId else null @@ -190,22 +191,29 @@ class WireActivityViewModel @Inject constructor( private lateinit var validSessions: StateFlow> init { - observeSyncState() observeUpdateAppState() - observeNewClientState() - observeScreenshotCensoringConfigState() - observeCurrentUserState() observeAppThemeState() + // These collectors are safe to start before foreground navigation. The current-session + // collector is itself the preparation gate, while the others only dereference a session + // after it has been published through preparedCurrentSession. + observeCurrentUserState() + observeSyncState() + observeScreenshotCensoringConfigState() observeSelectedAccent() observeLogoutState() observeBackendWebsiteUrl() resetNewRegistrationAnalyticsState() - viewModelScope.launch(dispatchers.io()) { monitorSyncWorkUseCase() } } - private suspend fun shouldEnrollToE2ei(userId: UserId): Boolean = - observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(userId) - .observeIfE2EIIsRequiredDuringLogin().first() ?: false + private suspend fun shouldEnrollToE2ei(sessionScope: UserSessionScope): Boolean = + sessionScope.observeIfE2EIRequiredDuringLogin().first() ?: false + + private fun startSessionDependentObservers() { + if (!sessionObserversStarted.compareAndSet(false, true)) return + + observeNewClientState() + viewModelScope.launch(dispatchers.io()) { monitorSyncWorkUseCase() } + } private fun observeAppThemeState() { viewModelScope.launch(dispatchers.io()) { @@ -219,9 +227,9 @@ class WireActivityViewModel @Inject constructor( private fun observeSelectedAccent() { viewModelScope.launch(dispatchers.io()) { - observeCurrentValidUserId.flatMapLatest { - it?.let { - observeSelfUserFactory.create(it).observeSelfUser().map { user -> + preparedCurrentSession.flatMapLatest { preparedSession -> + preparedSession?.let { + it.sessionScope.users.observeSelfUser().map { user -> Accent.fromAccentId(user.accentId) } } ?: flowOf(Accent.Unknown) @@ -235,10 +243,10 @@ class WireActivityViewModel @Inject constructor( private fun observeBackendWebsiteUrl() { viewModelScope.launch(dispatchers.io()) { - observeCurrentValidUserId.collectLatest { userId -> - val serverLinks = userId?.let { + preparedCurrentSession.collectLatest { preparedSession -> + val serverLinks = preparedSession?.let { runCatching { - when (val result = coreLogic.value.getSessionScope(it).users.serverLinks()) { + when (val result = it.sessionScope.users.serverLinks()) { is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links is SelfServerConfigUseCase.Result.Failure -> null } @@ -257,10 +265,10 @@ class WireActivityViewModel @Inject constructor( private fun observeSyncState() { viewModelScope.launch(dispatchers.io()) { - observeCurrentValidUserId - .flatMapLatest { userId -> - userId?.let { - observeSyncStateUseCaseProviderFactory.create(userId).observeSyncState() + preparedCurrentSession + .flatMapLatest { preparedSession -> + preparedSession?.let { + it.sessionScope.observeSyncState() } ?: flowOf(null) } .distinctUntilChanged() @@ -273,15 +281,22 @@ class WireActivityViewModel @Inject constructor( private fun observeCurrentUserState() { viewModelScope.launch(dispatchers.io()) { - observeCurrentUserId.collectLatest { + observeCurrentUserId.collectLatest { userId -> + val sessionScope = userId?.let { prepareUserSession(it) } + val readyUserId = userId?.takeIf { sessionScope != null } + preparedCurrentSession.value = if (readyUserId != null && sessionScope != null) { + PreparedCurrentSession(readyUserId, sessionScope) + } else { + null + } globalAppState = globalAppState.copy( - currentUserId = it, - isSessionTransitionInProgress = if (it != null) { + currentUserId = readyUserId, + isSessionTransitionInProgress = if (readyUserId != null) { false } else { globalAppState.isSessionTransitionInProgress }, - sessionTransitionReason = if (it != null) { + sessionTransitionReason = if (readyUserId != null) { null } else { globalAppState.sessionTransitionReason @@ -323,7 +338,13 @@ class WireActivityViewModel @Inject constructor( currentScreenManager.value.observeCurrentScreen(this) .flatMapLatest { if (it.isGlobalDialogAllowed()) { - observeNewClients.value.invoke() + flow { + if (prepareValidSessionsForNewClientObservation()) { + emitAll(observeNewClients.value.invoke()) + } else { + emit(NewClientResult.Empty) + } + } } else { flowOf(NewClientResult.Empty) } @@ -335,13 +356,23 @@ class WireActivityViewModel @Inject constructor( } } + private suspend fun prepareValidSessionsForNewClientObservation(): Boolean { + val sessions = if (::validSessions.isInitialized) { + validSessions.value + } else { + validSessionsFlow().first() + } + return sessions + .filter { it.isValid() } + .all { prepareUserSession(it.userId) != null } + } + private fun observeScreenshotCensoringConfigState() { viewModelScope.launch(dispatchers.io()) { - observeCurrentValidUserId - .flatMapLatest { currentValidUserId -> - currentValidUserId?.let { - observeScreenshotCensoringConfigUseCaseProviderFactory.create(it) - .observeScreenshotCensoringConfig() + preparedCurrentSession + .flatMapLatest { preparedSession -> + preparedSession?.let { + it.sessionScope.observeScreenshotCensoringConfig() .map { result -> result is ObserveScreenshotCensoringConfigResult.Enabled } @@ -364,22 +395,95 @@ class WireActivityViewModel @Inject constructor( } suspend fun initialAppState(): InitialAppState = withContext(dispatchers.io()) { + withContext(dispatchers.main()) { + userSessionPreparationState = UserSessionPreparationUiState.ResolvingSession + } initValidSessionsFlowIfNeeded() val currentValidUserId = resolveInitialCurrentUserId() + val preparedSessionScope = currentValidUserId?.let { prepareUserSession(it) } + preparedCurrentSession.value = if (currentValidUserId != null && preparedSessionScope != null) { + PreparedCurrentSession(currentValidUserId, preparedSessionScope) + } else { + null + } withContext(dispatchers.main()) { globalAppState = globalAppState.copy( - currentUserId = currentValidUserId, + currentUserId = currentValidUserId?.takeIf { preparedSessionScope != null }, isSessionTransitionInProgress = false, sessionTransitionReason = null, ) } + if (currentValidUserId != null && preparedSessionScope == null) { + return@withContext InitialAppState.SessionPreparationFailed + } + startSessionDependentObservers() when { - currentValidUserId == null -> InitialAppState.NotLoggedIn - shouldEnrollToE2ei(currentValidUserId) -> InitialAppState.EnrollE2EI(currentValidUserId) + currentValidUserId == null -> { + withContext(dispatchers.main()) { + userSessionPreparationState = UserSessionPreparationUiState.Ready + } + InitialAppState.NotLoggedIn + } + shouldEnrollToE2ei(checkNotNull(preparedSessionScope)) -> InitialAppState.EnrollE2EI(currentValidUserId) else -> InitialAppState.LoggedIn } } + fun preparedUserSessionScope(userId: UserId): UserSessionScope? = preparedSessionScopes[userId] + + fun retryPendingUserSessionPreparation() { + val userId = pendingPreparationUserId ?: return + viewModelScope.launch(dispatchers.io()) { + val sessionScope = prepareUserSession(userId) ?: return@launch + withContext(dispatchers.main()) { + globalAppState = globalAppState.copy( + currentUserId = userId, + isSessionTransitionInProgress = false, + sessionTransitionReason = null, + ) + } + preparedSessionScopes[userId] = sessionScope + preparedCurrentSession.value = PreparedCurrentSession(userId, sessionScope) + } + } + + private suspend fun prepareUserSession(userId: UserId): UserSessionScope? { + pendingPreparationUserId = userId + + val result = coroutineScope { + // States are picked up off the main thread so a busy first frame cannot swallow + // MigratingDatabase, then drained in order once the main thread is free again. + val observer = launch(dispatchers.main(), start = CoroutineStart.UNDISPATCHED) { + userSessionPreparationGate.observe(userId) + .toUiStates() + .flowOn(dispatchers.io()) + .collect { state -> userSessionPreparationState = state } + } + try { + userSessionPreparationGate.prepare(userId) + } finally { + observer.cancelAndJoin() + } + } + + return when (result) { + is AppUserSessionPreparationResult.Ready -> result.sessionScope.also { sessionScope -> + preparedSessionScopes[userId] = sessionScope + pendingPreparationUserId = null + withContext(dispatchers.main()) { + userSessionPreparationState = UserSessionPreparationUiState.Ready + } + } + + is AppUserSessionPreparationResult.Failed -> { + withContext(dispatchers.main()) { + userSessionPreparationState = UserSessionPreparationUiState.Failed(result.reason.toUiFailure()) + } + null + } + } + } + private suspend fun handleInvalidSession(logoutReason: LogoutReason) { when (logoutReason) { LogoutReason.SELF_SOFT_LOGOUT, LogoutReason.SELF_HARD_LOGOUT -> { @@ -428,8 +532,8 @@ class WireActivityViewModel @Inject constructor( is CurrentSessionResult.Failure.Generic -> null CurrentSessionResult.Failure.SessionNotFound -> null is CurrentSessionResult.Success -> { - coreLogic.value.sessionScope(currentSession.accountInfo.userId) { - when (val result = debug.synchronizeExternalData(InputStreamReader(data).readText())) { + prepareUserSession(currentSession.accountInfo.userId)?.let { sessionScope -> + when (val result = sessionScope.debug.synchronizeExternalData(InputStreamReader(data).readText())) { is SynchronizeExternalDataResult.Success -> { appLogger.d("Synchronized external data") } @@ -654,7 +758,7 @@ class WireActivityViewModel @Inject constructor( } } val logoutReason = if (wipeData) LogoutReason.SELF_HARD_LOGOUT else LogoutReason.SELF_SOFT_LOGOUT - coreLogic.value.getSessionScope(currentUserId).logout(logoutReason) + preparedSessionScopes[currentUserId]?.logout(logoutReason) if (wipeData) { clearUserData(currentUserId) } @@ -765,6 +869,7 @@ class WireActivityViewModel @Inject constructor( private suspend fun isCrossBackendLoginBlocked(target: IsCrossBackendLoginBlockedUseCase.Target): Boolean = coreLogic.value.getGlobalScope().isCrossBackendLoginBlocked(target) + @Suppress("NestedBlockDepth") private suspend fun onConversationInviteDeepLink( code: String, key: String, @@ -774,8 +879,8 @@ class WireActivityViewModel @Inject constructor( is CurrentSessionResult.Failure.Generic -> null CurrentSessionResult.Failure.SessionNotFound -> null is CurrentSessionResult.Success -> { - coreLogic.value.sessionScope(currentSession.accountInfo.userId) { - when (val result = conversations.checkIConversationInviteCode(code, key, domain)) { + prepareUserSession(currentSession.accountInfo.userId)?.let { sessionScope -> + when (val result = sessionScope.conversations.checkIConversationInviteCode(code, key, domain)) { is CheckConversationInviteCodeUseCase.Result.Success -> { if (result.isSelfMember) { // TODO; display messsage that user is already a member and ask if they want to navigate to the conversation @@ -825,15 +930,22 @@ class WireActivityViewModel @Inject constructor( val userId = withContext(dispatchers.io()) { currentSessionUserId() } + val sessionScope = userId?.let { prepareUserSession(it) } + val readyUserId = userId?.takeIf { sessionScope != null } + preparedCurrentSession.value = if (readyUserId != null && sessionScope != null) { + PreparedCurrentSession(readyUserId, sessionScope) + } else { + null + } withContext(dispatchers.main()) { globalAppState = globalAppState.copy( - currentUserId = userId, - isSessionTransitionInProgress = if (userId != null) { + currentUserId = readyUserId, + isSessionTransitionInProgress = if (readyUserId != null) { false } else { globalAppState.isSessionTransitionInProgress }, - sessionTransitionReason = if (userId != null) { + sessionTransitionReason = if (readyUserId != null) { null } else { globalAppState.sessionTransitionReason @@ -910,8 +1022,8 @@ class WireActivityViewModel @Inject constructor( } private fun onOpenUserProfileDeepLink(result: DeepLinkResult.OpenOtherUserProfile) = viewModelScope.launch { - observeCurrentValidUserId.first()?.let { userId -> - if (isProfileQRCodeEnabledFactory.create(userId).isProfileQRCodeEnabled()) { + preparedCurrentSession.first()?.let { preparedSession -> + if (isProfileQRCodeEnabledFactory.create(preparedSession.userId).isProfileQRCodeEnabled()) { sendAction(OnOpenUserProfile(result)) } else { sendAction(ShowToast(R.string.profile_deeplink_feature_unavailable_title_alert)) @@ -1016,8 +1128,14 @@ sealed interface InitialAppState { data object NotLoggedIn : InitialAppState data object LoggedIn : InitialAppState data class EnrollE2EI(val userId: UserId) : InitialAppState + data object SessionPreparationFailed : InitialAppState } +private data class PreparedCurrentSession( + val userId: UserId, + val sessionScope: UserSessionScope, +) + sealed interface WireActivityViewAction internal data class OpenConversation(val result: DeepLinkResult.OpenConversation) : WireActivityViewAction internal data object OnShowImportMediaScreen : WireActivityViewAction diff --git a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt index bf9b9a5e8f..0e6563be6a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.wire.android.di.metro.AppSessionViewModelGraph import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -41,8 +42,8 @@ class SessionGraphStoreViewModelTest { val oldGraph = graphWith(oldImageLoader) val newGraph = graphWith(mockk(relaxed = true)) val graphs = ArrayDeque(listOf(oldGraph, newGraph)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } - val oldRetainedGraph = store.retainedGraphFor(USER_ID) + val store = SessionGraphStoreViewModel { _, _ -> graphs.removeFirst() } + val oldRetainedGraph = store.retainedGraphFor(USER_ID, SESSION_SCOPE) val oldViewModel = TrackingViewModel() ViewModelProvider( oldRetainedGraph, @@ -50,7 +51,7 @@ class SessionGraphStoreViewModelTest { )[TrackingViewModel::class.java] store.invalidate(USER_ID) - val newRetainedGraph = store.retainedGraphFor(USER_ID) + val newRetainedGraph = store.retainedGraphFor(USER_ID, SESSION_SCOPE) assertNotSame(oldRetainedGraph, newRetainedGraph) assertSame(newGraph, newRetainedGraph.graph) @@ -64,11 +65,11 @@ class SessionGraphStoreViewModelTest { val oldGraph = graphWith(oldImageLoader) val newGraph = graphWith(mockk(relaxed = true)) val graphs = ArrayDeque(listOf(oldGraph, newGraph)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } - val oldRetainedGraph = store.retainedGraphFor(USER_ID) + val store = SessionGraphStoreViewModel { _, _ -> graphs.removeFirst() } + val oldRetainedGraph = store.retainedGraphFor(USER_ID, SESSION_SCOPE) store.invalidateActive() - val newRetainedGraph = store.retainedGraphFor(USER_ID) + val newRetainedGraph = store.retainedGraphFor(USER_ID, SESSION_SCOPE) assertNotSame(oldRetainedGraph, newRetainedGraph) assertSame(newGraph, newRetainedGraph.graph) @@ -78,10 +79,10 @@ class SessionGraphStoreViewModelTest { @Test fun givenSessionGraphIsStillValid_whenRequestedAgain_thenGraphIsReused() { val graph = graphWith(mockk(relaxed = true)) - val store = SessionGraphStoreViewModel { graph } + val store = SessionGraphStoreViewModel { _, _ -> graph } - val first = store.retainedGraphFor(USER_ID) - val second = store.retainedGraphFor(USER_ID) + val first = store.retainedGraphFor(USER_ID, SESSION_SCOPE) + val second = store.retainedGraphFor(USER_ID, SESSION_SCOPE) assertSame(first, second) } @@ -90,11 +91,11 @@ class SessionGraphStoreViewModelTest { fun givenSameUserIsRemovedAndLogsInRepeatedly_whenGraphsAreInvalidated_thenEveryLoginGetsAFreshGraph() { val imageLoaders = List(RELOGIN_COUNT) { mockk(relaxed = true) } val graphs = ArrayDeque(imageLoaders.map(::graphWith)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } + val store = SessionGraphStoreViewModel { _, _ -> graphs.removeFirst() } val retainedGraphs = buildList { repeat(RELOGIN_COUNT) { index -> - add(store.retainedGraphFor(USER_ID)) + add(store.retainedGraphFor(USER_ID, SESSION_SCOPE)) if (index < RELOGIN_COUNT - 1) { store.invalidate(USER_ID) } @@ -119,14 +120,14 @@ class SessionGraphStoreViewModelTest { USER_ID to graphWith(removedUserImageLoader), OTHER_USER_ID to graphWith(otherUserImageLoader), ) - val store = SessionGraphStoreViewModel { userId -> graphsByUser.getValue(userId) } - val otherUsersGraph = store.retainedGraphFor(OTHER_USER_ID) - val removedUsersGraph = store.retainedGraphFor(USER_ID) + val store = SessionGraphStoreViewModel { userId, _ -> graphsByUser.getValue(userId) } + val otherUsersGraph = store.retainedGraphFor(OTHER_USER_ID, OTHER_SESSION_SCOPE) + val removedUsersGraph = store.retainedGraphFor(USER_ID, SESSION_SCOPE) store.invalidateActive() - assertSame(otherUsersGraph, store.retainedGraphFor(OTHER_USER_ID)) - assertNotSame(removedUsersGraph, store.retainedGraphFor(USER_ID)) + assertSame(otherUsersGraph, store.retainedGraphFor(OTHER_USER_ID, OTHER_SESSION_SCOPE)) + assertNotSame(removedUsersGraph, store.retainedGraphFor(USER_ID, SESSION_SCOPE)) verify(exactly = 1) { removedUserImageLoader.shutdown() } verify(exactly = 0) { otherUserImageLoader.shutdown() } } @@ -148,5 +149,7 @@ class SessionGraphStoreViewModelTest { const val RELOGIN_COUNT = 3 val USER_ID = UserId("user", "domain") val OTHER_USER_ID = UserId("other-user", "domain") + val SESSION_SCOPE = mockk() + val OTHER_SESSION_SCOPE = mockk() } } diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index ff99cd315b..c8eca73262 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -35,10 +35,6 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.IsProfileQRCodeEnabledUseCaseProvider -import com.wire.android.di.ObserveIfE2EIRequiredDuringLoginUseCaseProvider -import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider -import com.wire.android.di.ObserveSelfUserUseCaseProvider -import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountParam @@ -67,6 +63,9 @@ import com.wire.android.util.lifecycle.IntentsProcessor import com.wire.android.util.newServerConfig import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.PrepareUserSessionResult +import com.wire.kalium.logic.UserSessionPreparationFailure +import com.wire.kalium.logic.UserSessionPreparationState import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus @@ -80,6 +79,7 @@ import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.appVersioning.ObserveIfAppUpdateRequiredUseCase +import com.wire.kalium.logic.feature.UserSessionScope import com.wire.kalium.logic.feature.auth.AuthenticationScope import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase @@ -112,6 +112,8 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -120,10 +122,12 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -183,6 +187,42 @@ class WireActivityViewModelTest { assertEquals(TEST_ACCOUNT_INFO.userId, viewModel.globalAppState.currentUserId) } + @Test + fun givenUserDatabaseIsStillPreparing_whenForegroundStarts_thenSessionStateIsNotPublishedUntilReady() = runTest { + val preparation = CompletableDeferred() + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeferredPreparation(preparation) + .arrange() + + val initialState = async { viewModel.initialAppState() } + runCurrent() + + assertNull(viewModel.globalAppState.currentUserId) + assertEquals(UserSessionPreparationUiState.ResolvingSession, viewModel.userSessionPreparationState) + + preparation.complete(arrangement.preparationSuccess()) + + assertEquals(InitialAppState.LoggedIn, initialState.await()) + assertEquals(TEST_ACCOUNT_INFO.userId, viewModel.globalAppState.currentUserId) + assertEquals(UserSessionPreparationUiState.Ready, viewModel.userSessionPreparationState) + } + + @Test + fun givenPreparationNeedsApplicationUpdate_whenForegroundStarts_thenActionableFailureIsExposed() = runTest { + val (_, viewModel) = Arrangement() + .withSomeCurrentSession() + .withPreparationFailure(UserSessionPreparationFailure.ApplicationUpdateRequired) + .arrange() + + assertEquals(InitialAppState.SessionPreparationFailed, viewModel.initialAppState()) + assertEquals( + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.ApplicationUpdateRequired), + viewModel.userSessionPreparationState, + ) + assertNull(viewModel.globalAppState.currentUserId) + } + @Test fun `given Intent with SSOLogin, when currentSession is present, then return SSOLogin result`() = runTest { val result = DeepLinkResult.SSOLogin.Success("cookie", "config") @@ -633,6 +673,8 @@ class WireActivityViewModelTest { .withNewClient(NewClientResult.InCurrentAccount(listOf(TestClient.CLIENT), USER_ID)) .withCurrentScreen(MutableStateFlow(CurrentScreen.SomeOther())) .arrange() + viewModel.initialAppState() + advanceUntilIdle() assertEquals( NewClientsData.CurrentUser(listOf(NewClientInfo.fromClient(TestClient.CLIENT)), USER_ID), @@ -647,6 +689,8 @@ class WireActivityViewModelTest { .withNewClient(NewClientResult.InOtherAccount(listOf(TestClient.CLIENT), USER_ID, "name", "handle")) .withCurrentScreen(MutableStateFlow(CurrentScreen.SomeOther())) .arrange() + viewModel.initialAppState() + advanceUntilIdle() assertEquals( NewClientsData.OtherUser( @@ -668,6 +712,7 @@ class WireActivityViewModelTest { .withNewClient(newClientFlow) .withCurrentScreen(currentScreenFlow) .arrange() + viewModel.initialAppState() currentScreenFlow.value = CurrentScreen.ImportMedia newClientFlow.emit(NewClientResult.InCurrentAccount(listOf(TestClient.CLIENT), USER_ID)) @@ -685,6 +730,7 @@ class WireActivityViewModelTest { .withNewClient(NewClientResult.InCurrentAccount(listOf(TestClient.CLIENT), USER_ID)) .withCurrentScreen(currentScreenFlow) .arrange() + viewModel.initialAppState() currentScreenFlow.value = CurrentScreen.ImportMedia @@ -1225,6 +1271,7 @@ class WireActivityViewModelTest { val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) private val persistentWebSocketEnforcedByMDMFlow = MutableStateFlow(false) + private val sessionScopes = mutableMapOf() private var defaultServerConfig = newServerConfig(0).links val automatedLoginManager = AutomatedLoginManager() @@ -1236,29 +1283,27 @@ class WireActivityViewModelTest { mockUri() coEvery { monitorSyncWorkUseCase() } returns Unit coEvery { currentSessionFlow() } returns flowOf() + coEvery { coreLogic.prepareUserSession(any()) } returns preparationSuccess() + every { coreLogic.observeUserSessionPreparation(any()) } returns flowOf(UserSessionPreparationState.NotStarted) + coEvery { userSessionScope.observeIfE2EIRequiredDuringLogin() } returns flowOf(false) coEvery { coreLogic.getGlobalScope().session.currentSession() } returns CurrentSessionResult.Failure.SessionNotFound coEvery { getServerConfigUseCase(any()) } returns GetServerConfigResult.Success(newServerConfig(1).links) coEvery { deepLinkProcessor(any(), any()) } returns DeepLinkResult.Unknown coEvery { intentsProcessor(any()) } returns null coEvery { observeSessionsUseCase.invoke() } returns flowOf(GetAllSessionsResult.Failure.NoSessionFound) - every { observeSyncStateUseCaseProviderFactory.create(any()).observeSyncState } returns observeSyncStateUseCase + every { userSessionScope.observeSyncState } returns observeSyncStateUseCase every { observeSyncStateUseCase() } returns emptyFlow() coEvery { observeIfAppUpdateRequired(any()) } returns flowOf(false) coEvery { observeNewClients() } returns flowOf() - every { observeScreenshotCensoringConfigUseCaseProviderFactory.create(any()).observeScreenshotCensoringConfig } returns - observeScreenshotCensoringConfigUseCase + every { userSessionScope.observeScreenshotCensoringConfig } returns observeScreenshotCensoringConfigUseCase every { observeScreenshotCensoringConfigUseCase.invoke() } returns flowOf(ObserveScreenshotCensoringConfigResult.Disabled) coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(CurrentScreen.SomeOther()) coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(ThemeOption.LIGHT) - coEvery { - observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(any()).observeIfE2EIIsRequiredDuringLogin() - } returns - flowOf(false) every { workManager.cancelAllWorkByTag(any()) } returns mockk() every { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns mockk() val observeSelfUserUseCase = mockk() - every { observeSelfUserFactory.create(any()).observeSelfUser } returns observeSelfUserUseCase + every { userSessionScope.users.observeSelfUser } returns observeSelfUserUseCase coEvery { observeSelfUserUseCase() } returns flowOf(SELF_USER) every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedByMDMFlow every { managedConfigurationsManager.currentServerConfig } returns null @@ -1299,10 +1344,10 @@ class WireActivityViewModelTest { private lateinit var observeSyncStateUseCase: ObserveSyncStateUseCase @MockK - private lateinit var observeSyncStateUseCaseProviderFactory: ObserveSyncStateUseCaseProvider.Factory + private lateinit var coreLogic: CoreLogic @MockK - private lateinit var coreLogic: CoreLogic + lateinit var userSessionScope: UserSessionScope @MockK private lateinit var autoVersionAuthScopeUseCase: AutoVersionAuthScopeUseCase @@ -1331,12 +1376,6 @@ class WireActivityViewModelTest { @MockK lateinit var observeScreenshotCensoringConfigUseCase: ObserveScreenshotCensoringConfigUseCase - @MockK - private lateinit var observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory - - @MockK - private lateinit var observeIfE2EIRequiredDuringLoginUseCaseProviderFactory: ObserveIfE2EIRequiredDuringLoginUseCaseProvider.Factory - @MockK lateinit var globalDataStore: GlobalDataStore @@ -1349,9 +1388,6 @@ class WireActivityViewModelTest { @MockK lateinit var isProfileQRCodeEnabledFactory: IsProfileQRCodeEnabledUseCaseProvider.Factory - @MockK - lateinit var observeSelfUserFactory: ObserveSelfUserUseCaseProvider.Factory - @MockK lateinit var monitorSyncWorkUseCase: MonitorSyncWorkUseCase @@ -1376,17 +1412,13 @@ class WireActivityViewModelTest { observeSessions = lazyOf(observeSessionsUseCase), accountSwitch = lazyOf(switchAccount), servicesManager = lazyOf(servicesManager), - observeSyncStateUseCaseProviderFactory = observeSyncStateUseCaseProviderFactory, observeIfAppUpdateRequired = lazyOf(observeIfAppUpdateRequired), observeNewClients = lazyOf(observeNewClients), clearNewClientsForUser = lazyOf(clearNewClientsForUser), currentScreenManager = lazyOf(currentScreenManager), - observeScreenshotCensoringConfigUseCaseProviderFactory = observeScreenshotCensoringConfigUseCaseProviderFactory, globalDataStore = lazyOf(globalDataStore), - observeIfE2EIRequiredDuringLoginUseCaseProviderFactory = observeIfE2EIRequiredDuringLoginUseCaseProviderFactory, workManager = lazyOf(workManager), isProfileQRCodeEnabledFactory = isProfileQRCodeEnabledFactory, - observeSelfUserFactory = observeSelfUserFactory, monitorSyncWorkUseCase = monitorSyncWorkUseCase, managedConfigurationsManager = managedConfigurationsManager, defaultServerConfig = defaultServerConfig, @@ -1403,6 +1435,21 @@ class WireActivityViewModelTest { coEvery { doesValidSessionExist(any()) } returns DoesValidSessionExistResult.Success(true) } + fun withDeferredPreparation(result: CompletableDeferred): Arrangement = apply { + coEvery { coreLogic.prepareUserSession(any()) } coAnswers { result.await() } + } + + fun withPreparationFailure(reason: UserSessionPreparationFailure): Arrangement = apply { + val result = mockk() + every { result.reason } returns reason + coEvery { coreLogic.prepareUserSession(any()) } returns result + } + + fun preparationSuccess(sessionScope: UserSessionScope = userSessionScope): PrepareUserSessionResult.Success = + mockk().also { result -> + every { result.sessionScope } returns sessionScope + } + fun withInvalidCurrentSession(logoutReason: LogoutReason): Arrangement = apply { coEvery { currentSessionFlow() } returns flowOf(CurrentSessionResult.Success(invalidAccountInfo(logoutReason))) coEvery { coreLogic.getGlobalScope().session.currentSession() } returns CurrentSessionResult.Success( @@ -1437,7 +1484,8 @@ class WireActivityViewModelTest { } fun withServerConfigForUser(userId: UserId, serverConfig: ServerConfig): Arrangement = apply { - coEvery { coreLogic.getSessionScope(userId).users.serverLinks() } returns + val sessionScope = sessionScopeFor(userId) + coEvery { sessionScope.users.serverLinks() } returns SelfServerConfigUseCase.Result.Success(serverConfig) } @@ -1450,9 +1498,7 @@ class WireActivityViewModelTest { } fun withE2EIRequiredDuringLogin(required: Boolean): Arrangement = apply { - coEvery { - observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(any()).observeIfE2EIIsRequiredDuringLogin() - } returns flowOf(required) + coEvery { userSessionScope.observeIfE2EIRequiredDuringLogin() } returns flowOf(required) } fun withObserveSessionsFlow(result: Flow): Arrangement = apply { @@ -1486,8 +1532,9 @@ class WireActivityViewModelTest { domain: String, result: CheckConversationInviteCodeUseCase.Result ): Arrangement = apply { + val sessionScope = sessionScopeFor(TEST_ACCOUNT_INFO.userId) coEvery { - coreLogic.getSessionScope(TEST_ACCOUNT_INFO.userId).conversations.checkIConversationInviteCode( + sessionScope.conversations.checkIConversationInviteCode( code, key, domain @@ -1518,7 +1565,7 @@ class WireActivityViewModelTest { fun withCurrentScreen(currentScreenFlow: StateFlow) = apply { coEvery { currentScreenManager.observeCurrentScreen(any()) } returns currentScreenFlow - coEvery { coreLogic.getSessionScope(TEST_ACCOUNT_INFO.userId).observeIfE2EIRequiredDuringLogin() } returns flowOf(false) + coEvery { userSessionScope.observeIfE2EIRequiredDuringLogin() } returns flowOf(false) } fun withNoNetworkConnectionWhenGettingServerConfig() = apply { @@ -1531,19 +1578,31 @@ class WireActivityViewModelTest { } suspend fun withScreenshotCensoringConfigForUser(id: UserId, result: ObserveScreenshotCensoringConfigResult) = apply { + val sessionScope = sessionScopeFor(id) val useCase = mockk() - coEvery { - observeScreenshotCensoringConfigUseCaseProviderFactory.create(id).observeScreenshotCensoringConfig - } returns useCase + every { sessionScope.observeScreenshotCensoringConfig } returns useCase coEvery { useCase() } returns flowOf(result) } fun withSyncStateForUser(id: UserId, result: SyncState) = apply { + val sessionScope = sessionScopeFor(id) val useCase = mockk() - coEvery { observeSyncStateUseCaseProviderFactory.create(id).observeSyncState } returns useCase + every { sessionScope.observeSyncState } returns useCase coEvery { useCase() } returns flowOf(result) } + private fun sessionScopeFor(userId: UserId): UserSessionScope = sessionScopes.getOrPut(userId) { + mockk(relaxed = true).also { sessionScope -> + coEvery { coreLogic.prepareUserSession(userId) } returns preparationSuccess(sessionScope) + coEvery { sessionScope.observeIfE2EIRequiredDuringLogin() } returns flowOf(false) + every { sessionScope.observeSyncState } returns observeSyncStateUseCase + every { sessionScope.observeScreenshotCensoringConfig } returns observeScreenshotCensoringConfigUseCase + val observeSelfUser = mockk() + every { sessionScope.users.observeSelfUser } returns observeSelfUser + coEvery { observeSelfUser() } returns flowOf(SELF_USER) + } + } + suspend fun withThemeOption(themeOption: ThemeOption) = apply { coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(themeOption) }