From 9077568051d46169bc10cbb97585ffa76db8d21d Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:01:26 +0200 Subject: [PATCH 1/7] feat(startup): add migration preparation screen --- .../ui/UserSessionPreparationScreen.kt | 327 ++++++++++++++++++ .../kotlin/com/wire/android/ui/common/Logo.kt | 8 +- app/src/main/res/values/strings.xml | 11 + .../session/UserSessionPreparationGateTest.kt | 132 +++++++ 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt 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 00000000000..0be2874f1b8 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -0,0 +1,327 @@ +/* + * 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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +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.theme.WireTheme +import com.wire.kalium.logic.UserSessionPreparationFailure +import com.wire.kalium.logic.UserSessionPreparationState +import kotlinx.coroutines.channels.Channel +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(colorResource(R.color.background)) + .padding(horizontal = 32.dp), + ) { + if (content.action == null) { + SplashContinuationContent(content) + } else { + PreparationFailureContent( + content = content, + onRetry = onRetry, + onUpdate = onUpdate, + onContactSupport = onContactSupport, + ) + } + } +} + +/** + * 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 = colorResource(R.color.default_icon_color), + 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(12.dp)) + 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(12.dp)) + Text( + text = stringResource(content.message), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + content.action?.let { action -> + Spacer(Modifier.height(32.dp)) + Button( + onClick = when (action) { + UserSessionPreparationAction.Retry -> onRetry + UserSessionPreparationAction.Update -> onUpdate + UserSessionPreparationAction.ContactSupport -> onContactSupport + } + ) { + Text(stringResource(action.label)) + } + } + } +} + +// 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 UserSessionPreparationStatePreviewProvider : + PreviewParameterProvider { + override val values: Sequence = sequenceOf( + UserSessionPreparationUiState.ResolvingSession, + UserSessionPreparationUiState.OpeningDatabase, + UserSessionPreparationUiState.MigratingDatabase, + UserSessionPreparationUiState.Ready, + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.InsufficientStorage), + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.TemporarilyUnavailable), + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.ApplicationUpdateRequired), + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.SupportRequired), + ) +} + +@Preview(name = "User session preparation", showBackground = false) +@Composable +private fun PreviewUserSessionPreparationScreen( + @PreviewParameter(UserSessionPreparationStatePreviewProvider::class) + state: UserSessionPreparationUiState, +) { + WireTheme { + UserSessionPreparationScreen( + state = state, + onRetry = {}, + onUpdate = {}, + onContactSupport = {}, + ) + } +} + +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 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(reason.toUiFailure()) +} + +internal fun UserSessionPreparationFailure.toUiFailure(): UserSessionPreparationUiFailure = when (this) { + UserSessionPreparationFailure.InsufficientStorage -> UserSessionPreparationUiFailure.InsufficientStorage + UserSessionPreparationFailure.TemporarilyUnavailable -> UserSessionPreparationUiFailure.TemporarilyUnavailable + UserSessionPreparationFailure.ApplicationUpdateRequired -> UserSessionPreparationUiFailure.ApplicationUpdateRequired + 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 + +@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 faf7f2ee670..26ef825396e 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 079a4fe6073..11336d388f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,17 @@ Removing… Logging in… Try Again + Preparing Wire + Opening your secure data. This may take a moment. + Updating your secure data + Keep Wire open while your messages are prepared for this version. + 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 57084c12818..67cb6a3770f 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,18 @@ */ package com.wire.android.session +import com.wire.android.ui.MIGRATION_SCREEN_MINIMUM_VISIBILITY +import com.wire.android.ui.MIGRATION_SCREEN_REVEAL_DELAY +import com.wire.android.ui.MigrationScreenVisibility +import com.wire.android.ui.UserSessionPreparationUiFailure +import com.wire.android.ui.UserSessionPreparationUiState +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 +37,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 +49,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 +106,120 @@ class UserSessionPreparationGateTest { } } + @Test + fun givenPublicPreparationFailures_whenMappingForForeground_thenEveryActionableStateIsPreserved() { + val mappings = mapOf( + UserSessionPreparationFailure.InsufficientStorage to UserSessionPreparationUiFailure.InsufficientStorage, + UserSessionPreparationFailure.TemporarilyUnavailable to UserSessionPreparationUiFailure.TemporarilyUnavailable, + UserSessionPreparationFailure.ApplicationUpdateRequired to + UserSessionPreparationUiFailure.ApplicationUpdateRequired, + UserSessionPreparationFailure.SupportRequired 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()) + } + + /** 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 +232,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 } } From 607db1abf9d105ace9a79ef6254730be1d757038 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:52:05 +0200 Subject: [PATCH 2/7] refactor(startup): use theme tokens for preparation screen --- .../android/ui/UserSessionPreparationScreen.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index 0be2874f1b8..c792a489f7b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -43,6 +42,8 @@ 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.kalium.logic.UserSessionPreparationFailure import com.wire.kalium.logic.UserSessionPreparationState @@ -65,8 +66,8 @@ internal fun UserSessionPreparationScreen( Box( modifier = Modifier .fillMaxSize() - .background(colorResource(R.color.background)) - .padding(horizontal = 32.dp), + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing32x), ) { if (content.action == null) { SplashContinuationContent(content) @@ -89,7 +90,7 @@ internal fun UserSessionPreparationScreen( @Composable private fun BoxScope.SplashContinuationContent(content: UserSessionPreparationContent) { Logo( - tint = colorResource(R.color.default_icon_color), + tint = colorsScheme().onSurface, modifier = Modifier .size(width = SPLASH_LOGO_WIDTH, height = SPLASH_LOGO_HEIGHT) .align(Alignment.Center), @@ -105,7 +106,7 @@ private fun BoxScope.SplashContinuationContent(content: UserSessionPreparationCo style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(dimensions().spacing12x)) Text( text = stringResource(content.message), style = MaterialTheme.typography.bodyLarge, @@ -131,14 +132,14 @@ private fun BoxScope.PreparationFailureContent( style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(dimensions().spacing12x)) Text( text = stringResource(content.message), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, ) content.action?.let { action -> - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(dimensions().spacing32x)) Button( onClick = when (action) { UserSessionPreparationAction.Retry -> onRetry From afa798ef945ce811ac4ca72d7ac3ffbd2eb23824 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:12:50 +0200 Subject: [PATCH 3/7] feat(startup): add migration progress screen --- .../ui/UserSessionPreparationScreen.kt | 84 +++++++++++++++++-- app/src/main/res/values/strings.xml | 3 +- .../session/UserSessionPreparationGateTest.kt | 16 ++++ 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index c792a489f7b..48a9d1c6077 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -28,10 +28,17 @@ 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.res.stringResource @@ -48,6 +55,7 @@ import com.wire.android.ui.theme.WireTheme 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 @@ -69,19 +77,56 @@ internal fun UserSessionPreparationScreen( .background(colorsScheme().background) .padding(horizontal = dimensions().spacing32x), ) { - if (content.action == null) { - SplashContinuationContent(content) - } else { - PreparationFailureContent( - content = content, - onRetry = onRetry, - onUpdate = onUpdate, - onContactSupport = onContactSupport, - ) + 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) + } + + Logo( + tint = colorsScheme().onBackground, + 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 + dimensions().spacing32x), + 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(SPLASH_LOGO_WIDTH), + color = colorsScheme().primary, + trackColor = colorsScheme().primaryVariant, + ) + } +} + /** * 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 @@ -234,6 +279,18 @@ internal class MigrationScreenVisibility( } } +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, @@ -286,6 +343,15 @@ 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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11336d388f2..dc07bf1f356 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,7 +50,8 @@ Preparing Wire Opening your secure data. This may take a moment. Updating your secure data - Keep Wire open while your messages are prepared for this version. + 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 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 67cb6a3770f..f6c3be50edb 100644 --- a/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt +++ b/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt @@ -17,11 +17,14 @@ */ 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 @@ -149,6 +152,19 @@ class UserSessionPreparationGateTest { 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 { From edde322df40287d26d1859b7b341405d25072b81 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:23:20 +0200 Subject: [PATCH 4/7] fix(startup): map preparation failures --- .../wire/android/ui/UserSessionPreparationScreen.kt | 10 +++++----- .../session/UserSessionPreparationGateTest.kt | 12 +++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index 48a9d1c6077..a9ee2944c9a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -315,14 +315,14 @@ internal fun UserSessionPreparationState.toUiState(): UserSessionPreparationUiSt UserSessionPreparationState.OpeningDatabase -> UserSessionPreparationUiState.OpeningDatabase UserSessionPreparationState.MigratingDatabase -> UserSessionPreparationUiState.MigratingDatabase UserSessionPreparationState.Ready -> UserSessionPreparationUiState.Ready - is UserSessionPreparationState.Failed -> UserSessionPreparationUiState.Failed(reason.toUiFailure()) + is UserSessionPreparationState.Failed -> UserSessionPreparationUiState.Failed(failure.toUiFailure()) } internal fun UserSessionPreparationFailure.toUiFailure(): UserSessionPreparationUiFailure = when (this) { - UserSessionPreparationFailure.InsufficientStorage -> UserSessionPreparationUiFailure.InsufficientStorage - UserSessionPreparationFailure.TemporarilyUnavailable -> UserSessionPreparationUiFailure.TemporarilyUnavailable - UserSessionPreparationFailure.ApplicationUpdateRequired -> UserSessionPreparationUiFailure.ApplicationUpdateRequired - UserSessionPreparationFailure.SupportRequired -> UserSessionPreparationUiFailure.SupportRequired + 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) { 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 f6c3be50edb..51d333dc629 100644 --- a/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt +++ b/app/src/test/kotlin/com/wire/android/session/UserSessionPreparationGateTest.kt @@ -111,12 +111,14 @@ class UserSessionPreparationGateTest { @Test fun givenPublicPreparationFailures_whenMappingForForeground_thenEveryActionableStateIsPreserved() { - val mappings = mapOf( - UserSessionPreparationFailure.InsufficientStorage to UserSessionPreparationUiFailure.InsufficientStorage, - UserSessionPreparationFailure.TemporarilyUnavailable to UserSessionPreparationUiFailure.TemporarilyUnavailable, - UserSessionPreparationFailure.ApplicationUpdateRequired to + val mappings: List> = listOf( + mockk() to + UserSessionPreparationUiFailure.InsufficientStorage, + mockk() to + UserSessionPreparationUiFailure.TemporarilyUnavailable, + mockk() to UserSessionPreparationUiFailure.ApplicationUpdateRequired, - UserSessionPreparationFailure.SupportRequired to UserSessionPreparationUiFailure.SupportRequired, + mockk() to UserSessionPreparationUiFailure.SupportRequired, ) mappings.forEach { (failure, expected) -> From 9061735d6dc591e7247d32fc55ac59eea88b4c37 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:51:48 +0200 Subject: [PATCH 5/7] fix(startup): match migration design --- .../ui/UserSessionPreparationScreen.kt | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index a9ee2944c9a..5dcb7990bd2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,6 +42,7 @@ 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.Preview @@ -100,16 +102,21 @@ private fun BoxScope.MigrationContent() { phase = migrationScreenPhase(MIGRATION_LONG_RUNNING_MESSAGE_DELAY) } + MigrationContent(phase) +} + +@Composable +private fun BoxScope.MigrationContent(phase: MigrationScreenPhase) { Logo( tint = colorsScheme().onBackground, modifier = Modifier - .size(width = SPLASH_LOGO_WIDTH, height = SPLASH_LOGO_HEIGHT) + .size(width = MIGRATION_CONTENT_WIDTH, height = MIGRATION_LOGO_HEIGHT) .align(Alignment.Center), ) Column( modifier = Modifier .align(Alignment.Center) - .offset(y = SPLASH_COPY_OFFSET + dimensions().spacing32x), + .offset(y = SPLASH_COPY_OFFSET), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -120,9 +127,10 @@ private fun BoxScope.MigrationContent() { ) Spacer(Modifier.height(dimensions().spacing20x)) LinearProgressIndicator( - modifier = Modifier.width(SPLASH_LOGO_WIDTH), + modifier = Modifier.width(MIGRATION_CONTENT_WIDTH), color = colorsScheme().primary, trackColor = colorsScheme().primaryVariant, + strokeCap = StrokeCap.Butt, ) } } @@ -198,38 +206,42 @@ private fun BoxScope.PreparationFailureContent( } } +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 UserSessionPreparationStatePreviewProvider : - PreviewParameterProvider { - override val values: Sequence = sequenceOf( - UserSessionPreparationUiState.ResolvingSession, - UserSessionPreparationUiState.OpeningDatabase, - UserSessionPreparationUiState.MigratingDatabase, - UserSessionPreparationUiState.Ready, - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.InsufficientStorage), - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.TemporarilyUnavailable), - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.ApplicationUpdateRequired), - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.SupportRequired), +private class MigrationScreenPhasePreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + MigrationScreenPhase.Updating, + MigrationScreenPhase.StillUpdating, ) } -@Preview(name = "User session preparation", showBackground = false) +@Preview( + name = "Active migration · Dark", + widthDp = 360, + heightDp = 800, + uiMode = UI_MODE_NIGHT_YES, + showSystemUi = true, +) @Composable private fun PreviewUserSessionPreparationScreen( - @PreviewParameter(UserSessionPreparationStatePreviewProvider::class) - state: UserSessionPreparationUiState, + @PreviewParameter(MigrationScreenPhasePreviewProvider::class) + phase: MigrationScreenPhase, ) { WireTheme { - UserSessionPreparationScreen( - state = state, - onRetry = {}, - onUpdate = {}, - onContactSupport = {}, - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing32x), + ) { + MigrationContent(phase) + } } } From 78a59ff35267f5ccd2c5a80d6a2ebb290159b673 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:00:18 +0200 Subject: [PATCH 6/7] chore(startup): preview all preparation states --- .../ui/UserSessionPreparationScreen.kt | 94 ++++++++++++++----- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index 5dcb7990bd2..3464788afb1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui -import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,7 +44,6 @@ 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.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp @@ -54,6 +52,7 @@ 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 @@ -71,6 +70,22 @@ internal fun UserSessionPreparationScreen( onRetry: () -> Unit, onUpdate: () -> Unit, onContactSupport: () -> Unit, +) { + UserSessionPreparationScreenContent( + state = state, + onRetry = onRetry, + onUpdate = onUpdate, + onContactSupport = onContactSupport, + ) +} + +@Composable +private fun UserSessionPreparationScreenContent( + state: UserSessionPreparationUiState, + onRetry: () -> Unit, + onUpdate: () -> Unit, + onContactSupport: () -> Unit, + migrationPhase: MigrationScreenPhase? = null, ) { val content = state.content() Box( @@ -80,7 +95,9 @@ internal fun UserSessionPreparationScreen( .padding(horizontal = dimensions().spacing32x), ) { when { - state == UserSessionPreparationUiState.MigratingDatabase -> MigrationContent() + state == UserSessionPreparationUiState.MigratingDatabase -> { + if (migrationPhase == null) MigrationContent() else MigrationContent(migrationPhase) + } content.action == null -> SplashContinuationContent(content) else -> { PreparationFailureContent( @@ -214,34 +231,63 @@ 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 = sequenceOf( - MigrationScreenPhase.Updating, - MigrationScreenPhase.StillUpdating, +private data class UserSessionPreparationPreview( + val name: String, + val state: UserSessionPreparationUiState, + val migrationPhase: MigrationScreenPhase? = null, +) + +private class UserSessionPreparationPreviewProvider : PreviewParameterProvider { + private val previews = listOf( + UserSessionPreparationPreview("Resolving session", UserSessionPreparationUiState.ResolvingSession), + UserSessionPreparationPreview("Opening database", UserSessionPreparationUiState.OpeningDatabase), + UserSessionPreparationPreview( + "Migrating database - updating", + UserSessionPreparationUiState.MigratingDatabase, + MigrationScreenPhase.Updating, + ), + UserSessionPreparationPreview( + "Migrating database - still updating", + UserSessionPreparationUiState.MigratingDatabase, + MigrationScreenPhase.StillUpdating, + ), + UserSessionPreparationPreview("Ready", UserSessionPreparationUiState.Ready), + UserSessionPreparationPreview( + "Insufficient storage", + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.InsufficientStorage), + ), + UserSessionPreparationPreview( + "Temporarily unavailable", + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.TemporarilyUnavailable), + ), + UserSessionPreparationPreview( + "Application update required", + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.ApplicationUpdateRequired), + ), + UserSessionPreparationPreview( + "Support required", + UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.SupportRequired), + ), ) + + override val values: Sequence = previews.asSequence() + override fun getDisplayName(index: Int): String? = previews.getOrNull(index)?.name } -@Preview( - name = "Active migration · Dark", - widthDp = 360, - heightDp = 800, - uiMode = UI_MODE_NIGHT_YES, - showSystemUi = true, -) +@PreviewMultipleThemes @Composable private fun PreviewUserSessionPreparationScreen( - @PreviewParameter(MigrationScreenPhasePreviewProvider::class) - phase: MigrationScreenPhase, + @PreviewParameter(UserSessionPreparationPreviewProvider::class) + preview: UserSessionPreparationPreview, ) { WireTheme { - Box( - modifier = Modifier - .fillMaxSize() - .background(colorsScheme().background) - .padding(horizontal = dimensions().spacing32x), - ) { - MigrationContent(phase) - } + UserSessionPreparationScreenContent( + state = preview.state, + onRetry = {}, + onUpdate = {}, + onContactSupport = {}, + migrationPhase = preview.migrationPhase, + ) } } From 3934536afb9903c70777cd1cd1c8c408d12814b2 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:21:43 +0200 Subject: [PATCH 7/7] chore(startup): limit previews to migration --- .../ui/UserSessionPreparationScreen.kt | 83 ++++--------------- 1 file changed, 14 insertions(+), 69 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt index 3464788afb1..5a28db8de6c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/UserSessionPreparationScreen.kt @@ -70,22 +70,6 @@ internal fun UserSessionPreparationScreen( onRetry: () -> Unit, onUpdate: () -> Unit, onContactSupport: () -> Unit, -) { - UserSessionPreparationScreenContent( - state = state, - onRetry = onRetry, - onUpdate = onUpdate, - onContactSupport = onContactSupport, - ) -} - -@Composable -private fun UserSessionPreparationScreenContent( - state: UserSessionPreparationUiState, - onRetry: () -> Unit, - onUpdate: () -> Unit, - onContactSupport: () -> Unit, - migrationPhase: MigrationScreenPhase? = null, ) { val content = state.content() Box( @@ -95,9 +79,7 @@ private fun UserSessionPreparationScreenContent( .padding(horizontal = dimensions().spacing32x), ) { when { - state == UserSessionPreparationUiState.MigratingDatabase -> { - if (migrationPhase == null) MigrationContent() else MigrationContent(migrationPhase) - } + state == UserSessionPreparationUiState.MigratingDatabase -> MigrationContent() content.action == null -> SplashContinuationContent(content) else -> { PreparationFailureContent( @@ -231,63 +213,26 @@ private val SPLASH_LOGO_WIDTH = 174.dp private val SPLASH_LOGO_HEIGHT = 55.dp private val SPLASH_COPY_OFFSET = 96.dp -private data class UserSessionPreparationPreview( - val name: String, - val state: UserSessionPreparationUiState, - val migrationPhase: MigrationScreenPhase? = null, -) - -private class UserSessionPreparationPreviewProvider : PreviewParameterProvider { - private val previews = listOf( - UserSessionPreparationPreview("Resolving session", UserSessionPreparationUiState.ResolvingSession), - UserSessionPreparationPreview("Opening database", UserSessionPreparationUiState.OpeningDatabase), - UserSessionPreparationPreview( - "Migrating database - updating", - UserSessionPreparationUiState.MigratingDatabase, - MigrationScreenPhase.Updating, - ), - UserSessionPreparationPreview( - "Migrating database - still updating", - UserSessionPreparationUiState.MigratingDatabase, - MigrationScreenPhase.StillUpdating, - ), - UserSessionPreparationPreview("Ready", UserSessionPreparationUiState.Ready), - UserSessionPreparationPreview( - "Insufficient storage", - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.InsufficientStorage), - ), - UserSessionPreparationPreview( - "Temporarily unavailable", - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.TemporarilyUnavailable), - ), - UserSessionPreparationPreview( - "Application update required", - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.ApplicationUpdateRequired), - ), - UserSessionPreparationPreview( - "Support required", - UserSessionPreparationUiState.Failed(UserSessionPreparationUiFailure.SupportRequired), - ), - ) - - override val values: Sequence = previews.asSequence() - override fun getDisplayName(index: Int): String? = previews.getOrNull(index)?.name +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(UserSessionPreparationPreviewProvider::class) - preview: UserSessionPreparationPreview, + @PreviewParameter(MigrationScreenPhasePreviewProvider::class) + phase: MigrationScreenPhase, ) { WireTheme { - UserSessionPreparationScreenContent( - state = preview.state, - onRetry = {}, - onUpdate = {}, - onContactSupport = {}, - migrationPhase = preview.migrationPhase, - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(colorsScheme().background) + .padding(horizontal = dimensions().spacing32x), + ) { + MigrationContent(phase) + } } }