diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt new file mode 100644 index 0000000000..5a28db8de6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -0,0 +1,397 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.wire.android.R +import com.wire.android.ui.common.Logo +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.UserSessionPreparationFailure +import com.wire.kalium.logic.UserSessionPreparationState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.map +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun UserSessionPreparationScreen( + state: UserSessionPreparationUiState, + onRetry: () -> Unit, + onUpdate: () -> Unit, + onContactSupport: () -> Unit, +) { + val content = state.content() + Box( + modifier = Modifier + .fillMaxSize() + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing32x), + ) { + when { + state == UserSessionPreparationUiState.MigratingDatabase -> MigrationContent() + content.action == null -> SplashContinuationContent(content) + else -> { + PreparationFailureContent( + content = content, + onRetry = onRetry, + onUpdate = onUpdate, + onContactSupport = onContactSupport, + ) + } + } + } +} + +@Composable +private fun BoxScope.MigrationContent() { + var phase by remember { mutableStateOf(MigrationScreenPhase.Updating) } + LaunchedEffect(Unit) { + delay(MIGRATION_LONG_RUNNING_MESSAGE_DELAY.inWholeMilliseconds) + phase = migrationScreenPhase(MIGRATION_LONG_RUNNING_MESSAGE_DELAY) + } + + MigrationContent(phase) +} + +@Composable +private fun BoxScope.MigrationContent(phase: MigrationScreenPhase) { + Logo( + tint = colorsScheme().onBackground, + modifier = Modifier + .size(width = MIGRATION_CONTENT_WIDTH, height = MIGRATION_LOGO_HEIGHT) + .align(Alignment.Center), + ) + Column( + modifier = Modifier + .align(Alignment.Center) + .offset(y = SPLASH_COPY_OFFSET), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(phase.message), + color = colorsScheme().onBackground, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(dimensions().spacing20x)) + LinearProgressIndicator( + modifier = Modifier.width(MIGRATION_CONTENT_WIDTH), + color = colorsScheme().primary, + trackColor = colorsScheme().primaryVariant, + strokeCap = StrokeCap.Butt, + ) + } +} + +/** + * Continues the system splash with its final, static logo frame. Keeping the logo centred means + * dismissing the system-owned splash does not look like a navigation event; only the explanatory + * copy below it becomes visible. + */ +@Composable +private fun BoxScope.SplashContinuationContent(content: UserSessionPreparationContent) { + Logo( + tint = colorsScheme().onSurface, + modifier = Modifier + .size(width = SPLASH_LOGO_WIDTH, height = SPLASH_LOGO_HEIGHT) + .align(Alignment.Center), + ) + Column( + modifier = Modifier + .align(Alignment.Center) + .offset(y = SPLASH_COPY_OFFSET), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(content.title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(dimensions().spacing12x)) + Text( + text = stringResource(content.message), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun BoxScope.PreparationFailureContent( + content: UserSessionPreparationContent, + onRetry: () -> Unit, + onUpdate: () -> Unit, + onContactSupport: () -> Unit, +) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(content.title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(dimensions().spacing12x)) + Text( + text = stringResource(content.message), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + content.action?.let { action -> + Spacer(Modifier.height(dimensions().spacing32x)) + Button( + onClick = when (action) { + UserSessionPreparationAction.Retry -> onRetry + UserSessionPreparationAction.Update -> onUpdate + UserSessionPreparationAction.ContactSupport -> onContactSupport + } + ) { + Text(stringResource(action.label)) + } + } + } +} + +private val MIGRATION_CONTENT_WIDTH = 212.dp +private val MIGRATION_LOGO_HEIGHT = 67.dp + +// The system splash uses a 288 dp icon canvas. Its Wire wordmark occupies roughly 174 x 55 dp. +private val SPLASH_LOGO_WIDTH = 174.dp +private val SPLASH_LOGO_HEIGHT = 55.dp +private val SPLASH_COPY_OFFSET = 96.dp + +private class MigrationScreenPhasePreviewProvider : PreviewParameterProvider { + override val values: Sequence = MigrationScreenPhase.entries.asSequence() + override fun getDisplayName(index: Int): String? = MigrationScreenPhase.entries.getOrNull(index)?.name +} + +@PreviewMultipleThemes +@Composable +private fun PreviewUserSessionPreparationScreen( + @PreviewParameter(MigrationScreenPhasePreviewProvider::class) + phase: MigrationScreenPhase, +) { + WireTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing32x), + ) { + MigrationContent(phase) + } + } +} + +internal sealed interface UserSessionPreparationUiState { + data object ResolvingSession : UserSessionPreparationUiState + data object OpeningDatabase : UserSessionPreparationUiState + data object MigratingDatabase : UserSessionPreparationUiState + data object Ready : UserSessionPreparationUiState + data class Failed(val failure: UserSessionPreparationUiFailure) : UserSessionPreparationUiState +} + +/** + * Keeps fast session opening behind the system splash. A migration earns dedicated UI only when + * it lasts long enough to avoid a flash; failures are revealed immediately so they stay actionable. + * + * Returning `null` means the state never justifies leaving the splash on its own. + */ +internal fun UserSessionPreparationUiState.preparationScreenRevealDelay(): Duration? = when (this) { + UserSessionPreparationUiState.MigratingDatabase -> MIGRATION_SCREEN_REVEAL_DELAY + is UserSessionPreparationUiState.Failed -> Duration.ZERO + UserSessionPreparationUiState.ResolvingSession, + UserSessionPreparationUiState.OpeningDatabase, + UserSessionPreparationUiState.Ready -> null +} + +/** + * Tracks how long the migration screen has been visible so it is never replaced mid-blink. + * + * The reveal delay already skips the screen entirely for migrations that finish quickly. Once the + * screen does appear, a migration finishing right after would swap it out instantly, which reads as + * a glitch, so callers wait out [remainingVisibility] before showing the next screen. + */ +internal class MigrationScreenVisibility( + private val minimumVisibility: Duration = MIGRATION_SCREEN_MINIMUM_VISIBILITY, + private val elapsedRealtimeMillis: () -> Long, +) { + private var revealedAtMillis: Long? = null + + fun onRevealed() { + if (revealedAtMillis == null) revealedAtMillis = elapsedRealtimeMillis() + } + + fun remainingVisibility(): Duration { + val revealedAt = revealedAtMillis ?: return Duration.ZERO + val visibleFor = (elapsedRealtimeMillis() - revealedAt).milliseconds + return (minimumVisibility - visibleFor).coerceAtLeast(Duration.ZERO) + } +} + +internal enum class MigrationScreenPhase { + Updating, + StillUpdating, +} + +internal fun migrationScreenPhase(elapsed: Duration): MigrationScreenPhase = + if (elapsed >= MIGRATION_LONG_RUNNING_MESSAGE_DELAY) { + MigrationScreenPhase.StillUpdating + } else { + MigrationScreenPhase.Updating + } + +internal enum class UserSessionPreparationUiFailure { + InsufficientStorage, + TemporarilyUnavailable, + ApplicationUpdateRequired, + SupportRequired, +} + +/** + * Maps Kalium's preparation states for a collector that cannot keep up with them. + * + * Kalium publishes on a conflated `StateFlow`, and the main thread is busy with the first frame + * during startup. Reading the states straight from the main thread lets a short + * [UserSessionPreparationState.MigratingDatabase] window be overwritten before it is ever seen, so + * the migration screen never gets a chance to appear. Buffering hands the collector every state in + * order instead, at the cost of observing them slightly later than they happened. + */ +internal fun Flow.toUiStates(): Flow = + map { it.toUiState() }.buffer(Channel.UNLIMITED) + +internal fun UserSessionPreparationState.toUiState(): UserSessionPreparationUiState = when (this) { + UserSessionPreparationState.NotStarted -> UserSessionPreparationUiState.ResolvingSession + UserSessionPreparationState.OpeningDatabase -> UserSessionPreparationUiState.OpeningDatabase + UserSessionPreparationState.MigratingDatabase -> UserSessionPreparationUiState.MigratingDatabase + UserSessionPreparationState.Ready -> UserSessionPreparationUiState.Ready + is UserSessionPreparationState.Failed -> UserSessionPreparationUiState.Failed(failure.toUiFailure()) +} + +internal fun UserSessionPreparationFailure.toUiFailure(): UserSessionPreparationUiFailure = when (this) { + is UserSessionPreparationFailure.InsufficientStorage -> UserSessionPreparationUiFailure.InsufficientStorage + is UserSessionPreparationFailure.TemporarilyUnavailable -> UserSessionPreparationUiFailure.TemporarilyUnavailable + is UserSessionPreparationFailure.ApplicationUpdateRequired -> UserSessionPreparationUiFailure.ApplicationUpdateRequired + is UserSessionPreparationFailure.SupportRequired -> UserSessionPreparationUiFailure.SupportRequired +} + +private enum class UserSessionPreparationAction(val label: Int) { + Retry(R.string.label_try_again), + Update(R.string.label_update), + ContactSupport(R.string.user_session_preparation_contact_support), +} + +private data class UserSessionPreparationContent( + val title: Int, + val message: Int, + val action: UserSessionPreparationAction? = null, +) + +/** How long a migration has to run before it is worth interrupting the splash for it. */ +internal val MIGRATION_SCREEN_REVEAL_DELAY: Duration = 500.milliseconds + +/** How long the migration screen stays up once revealed, even if the migration already finished. */ +internal val MIGRATION_SCREEN_MINIMUM_VISIBILITY: Duration = 1.seconds + +/** How long the initial migration copy is shown before reassuring the user that work is continuing. */ +internal val MIGRATION_LONG_RUNNING_MESSAGE_DELAY: Duration = 10.seconds + +private val MigrationScreenPhase.message: Int + get() = when (this) { + MigrationScreenPhase.Updating -> R.string.user_session_preparation_migrating_message + MigrationScreenPhase.StillUpdating -> R.string.user_session_preparation_migrating_long_running_message + } + +@Composable +private fun UserSessionPreparationUiState.content(): UserSessionPreparationContent = when (this) { + UserSessionPreparationUiState.ResolvingSession, + UserSessionPreparationUiState.OpeningDatabase, + UserSessionPreparationUiState.Ready -> UserSessionPreparationContent( + title = R.string.user_session_preparation_opening_title, + message = R.string.user_session_preparation_opening_message, + ) + + UserSessionPreparationUiState.MigratingDatabase -> UserSessionPreparationContent( + title = R.string.user_session_preparation_migrating_title, + message = R.string.user_session_preparation_migrating_message, + ) + + is UserSessionPreparationUiState.Failed -> when (failure) { + UserSessionPreparationUiFailure.InsufficientStorage -> UserSessionPreparationContent( + title = R.string.user_session_preparation_storage_title, + message = R.string.user_session_preparation_storage_message, + action = UserSessionPreparationAction.Retry, + ) + + UserSessionPreparationUiFailure.TemporarilyUnavailable -> UserSessionPreparationContent( + title = R.string.user_session_preparation_temporary_title, + message = R.string.user_session_preparation_temporary_message, + action = UserSessionPreparationAction.Retry, + ) + + UserSessionPreparationUiFailure.ApplicationUpdateRequired -> UserSessionPreparationContent( + title = R.string.update_app_dialog_title, + message = R.string.update_app_dialog_body, + action = UserSessionPreparationAction.Update, + ) + + UserSessionPreparationUiFailure.SupportRequired -> UserSessionPreparationContent( + title = R.string.user_session_preparation_support_title, + message = R.string.user_session_preparation_support_message, + action = UserSessionPreparationAction.ContactSupport, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/Logo.kt b/app/src/main/kotlin/com/wire/android/ui/common/Logo.kt index faf7f2ee67..26ef825396 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/Logo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/Logo.kt @@ -21,18 +21,22 @@ package com.wire.android.ui.common import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import com.wire.android.R @Composable -fun Logo(modifier: Modifier = Modifier) { +fun Logo( + modifier: Modifier = Modifier, + tint: Color = colorsScheme().onSurface, +) { Image( painter = painterResource(id = R.drawable.ic_wire_logo), contentDescription = null, contentScale = ContentScale.Fit, modifier = modifier, - colorFilter = ColorFilter.tint(colorsScheme().onSurface) + colorFilter = ColorFilter.tint(tint) ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 079a4fe607..dc07bf1f35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,18 @@ Removing… Logging in… Try Again + Preparing Wire + Opening your secure data. This may take a moment. + Updating your secure data + Updating your data... + Still updating. Almost there... + Free up storage + Wire needs more free space to safely update your secure data. Free up storage, then try again. + Wire is temporarily unavailable + Your secure data is busy right now. Wait a moment, then try again. + Contact Wire support + Wire could not safely open your secure data. Your data was not deleted or recreated. + Contact support Done Message could not be sent due to connectivity issues. The edited message could not be sent due to connectivity issues. diff --git a/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt b/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt index 57084c1281..51d333dc62 100644 --- a/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt +++ b/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt @@ -17,9 +17,21 @@ */ package com.wire.android.session +import com.wire.android.ui.MIGRATION_LONG_RUNNING_MESSAGE_DELAY +import com.wire.android.ui.MIGRATION_SCREEN_MINIMUM_VISIBILITY +import com.wire.android.ui.MIGRATION_SCREEN_REVEAL_DELAY +import com.wire.android.ui.MigrationScreenPhase +import com.wire.android.ui.MigrationScreenVisibility +import com.wire.android.ui.UserSessionPreparationUiFailure +import com.wire.android.ui.UserSessionPreparationUiState +import com.wire.android.ui.migrationScreenPhase +import com.wire.android.ui.preparationScreenRevealDelay +import com.wire.android.ui.toUiFailure +import com.wire.android.ui.toUiStates 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.data.user.UserId import com.wire.kalium.logic.feature.UserSessionScope import io.mockk.coEvery @@ -28,6 +40,11 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -35,6 +52,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds class UserSessionPreparationGateTest { @@ -90,6 +109,135 @@ class UserSessionPreparationGateTest { } } + @Test + fun givenPublicPreparationFailures_whenMappingForForeground_thenEveryActionableStateIsPreserved() { + val mappings: List> = listOf( + mockk() to + UserSessionPreparationUiFailure.InsufficientStorage, + mockk() to + UserSessionPreparationUiFailure.TemporarilyUnavailable, + mockk() to + UserSessionPreparationUiFailure.ApplicationUpdateRequired, + mockk() to UserSessionPreparationUiFailure.SupportRequired, + ) + + mappings.forEach { (failure, expected) -> + assertEquals(expected, failure.toUiFailure()) + } + } + + @Test + fun givenFastPreparationStates_whenChoosingVisibility_thenPreparationScreenStaysBehindSystemSplash() { + val hiddenStates = listOf( + UserSessionPreparationUiState.ResolvingSession, + UserSessionPreparationUiState.OpeningDatabase, + UserSessionPreparationUiState.Ready, + ) + + hiddenStates.forEach { state -> + assertEquals(null, state.preparationScreenRevealDelay()) + } + } + + @Test + fun givenMigrationState_whenChoosingVisibility_thenPreparationScreenIsDebounced() { + assertEquals( + MIGRATION_SCREEN_REVEAL_DELAY, + UserSessionPreparationUiState.MigratingDatabase.preparationScreenRevealDelay(), + ) + } + + @Test + fun givenFailureState_whenChoosingVisibility_thenPreparationScreenIsRevealedImmediately() { + val state = UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.SupportRequired) + + assertEquals(Duration.ZERO, state.preparationScreenRevealDelay()) + } + + @Test + fun givenVisibleMigration_whenChoosingCopy_thenLongRunningMessageStartsAtItsDelay() { + assertEquals(MigrationScreenPhase.Updating, migrationScreenPhase(Duration.ZERO)) + assertEquals( + MigrationScreenPhase.Updating, + migrationScreenPhase(MIGRATION_LONG_RUNNING_MESSAGE_DELAY - 1.milliseconds), + ) + assertEquals( + MigrationScreenPhase.StillUpdating, + migrationScreenPhase(MIGRATION_LONG_RUNNING_MESSAGE_DELAY), + ) + } + + /** Preserves a short migration state after observation starts while the main collector is busy. */ + @Test + fun givenStatesChangeWhileTheCollectorIsBusy_whenMappingForForeground_thenMigrationIsStillDelivered() = runTest { + val states = MutableStateFlow(UserSessionPreparationState.NotStarted) + val observed = mutableListOf() + val collector = launch(UnconfinedTestDispatcher(testScheduler)) { + states.toUiStates().collect { state -> + observed += state + delay(BUSY_COLLECTOR_DELAY) // stands in for a main thread stuck on the first frame + } + } + runCurrent() + + states.value = UserSessionPreparationState.OpeningDatabase + states.value = UserSessionPreparationState.MigratingDatabase + states.value = UserSessionPreparationState.Ready + advanceTimeBy(BUSY_COLLECTOR_DELAY * OBSERVED_STATE_COUNT) + collector.cancel() + + assertEquals( + listOf( + UserSessionPreparationUiState.ResolvingSession, + UserSessionPreparationUiState.OpeningDatabase, + UserSessionPreparationUiState.MigratingDatabase, + UserSessionPreparationUiState.Ready, + ), + observed, + ) + } + + @Test + fun givenMigrationScreenWasNeverRevealed_whenLeavingPreparation_thenNothingIsWaitedFor() { + val visibility = MigrationScreenVisibility(elapsedRealtimeMillis = { 0L }) + + assertEquals(Duration.ZERO, visibility.remainingVisibility()) + } + + @Test + fun givenMigrationFinishedRightAfterReveal_whenLeavingPreparation_thenRemainderOfMinimumIsWaitedFor() { + var now = 1_000L + val visibility = MigrationScreenVisibility(elapsedRealtimeMillis = { now }) + + visibility.onRevealed() + now += 200L + + assertEquals(MIGRATION_SCREEN_MINIMUM_VISIBILITY - 200.milliseconds, visibility.remainingVisibility()) + } + + @Test + fun givenMigrationOutlivedTheMinimum_whenLeavingPreparation_thenNothingIsWaitedFor() { + var now = 1_000L + val visibility = MigrationScreenVisibility(elapsedRealtimeMillis = { now }) + + visibility.onRevealed() + now += MIGRATION_SCREEN_MINIMUM_VISIBILITY.inWholeMilliseconds + 1L + + assertEquals(Duration.ZERO, visibility.remainingVisibility()) + } + + @Test + fun givenScreenAlreadyRevealed_whenRevealedAgain_thenMinimumStillCountsFromFirstReveal() { + var now = 1_000L + val visibility = MigrationScreenVisibility(elapsedRealtimeMillis = { now }) + + visibility.onRevealed() + now += 400L + visibility.onRevealed() + + assertEquals(MIGRATION_SCREEN_MINIMUM_VISIBILITY - 400.milliseconds, visibility.remainingVisibility()) + } + private fun success(sessionScope: UserSessionScope): PrepareUserSessionResult.Success = mockk().also { every { it.sessionScope } returns sessionScope @@ -102,5 +250,7 @@ class UserSessionPreparationGateTest { private companion object { val USER_ID = UserId("user", "wire.test") + const val BUSY_COLLECTOR_DELAY = 1_000L + const val OBSERVED_STATE_COUNT = 4 } }