From 562e4394473caca28604bc9ab208578679e42385 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Sep 2026 12:48:44 +0200 Subject: [PATCH 1/7] Show a loading indicator on the placeholders which can stay on screen forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emptyNode() renders nothing at all, which is fine for a placeholder that is left within a few frames, but makes the application look frozen when the wait can last, or never end. Add a delayBeforeShowingContent parameter to emptyNode(): when it is finite, a progress indicator and a "Loading…" label fade in after that delay, reusing the existing DelayedVisibility helper. It defaults to Duration.INFINITE, so the splash screen keeps rendering an empty canvas. Use a one second delay on the two placeholders which are not guaranteed to be left: the FTUE one, rendered while waiting for the session verified status, and the LoggedInFlowNode one, rendered while the FTUE state is unknown. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/LoggedInFlowNode.kt | 5 +- .../features/ftue/impl/FtueFlowNode.kt | 6 ++- libraries/ui-common/build.gradle.kts | 1 + .../libraries/ui/common/nodes/EmptyNode.kt | 53 +++++++++++++++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index dab9b7828c..4254d66664 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -113,6 +113,7 @@ import java.time.Duration import java.time.Instant import java.util.Optional import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toKotlinDuration @@ -324,7 +325,9 @@ class LoggedInFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Placeholder -> emptyNode(buildContext) + // Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all, + // since there is no guarantee that this state will be left. + NavTarget.Placeholder -> emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index 8d66258e12..b3d96a88e5 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.milliseconds @ContributesNode(SessionScope::class) @AssistedInject @@ -84,7 +85,10 @@ class FtueFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Placeholder -> { - emptyNode(buildContext) + // Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState). + // This can last for a while, or forever if the sync service never manages to run, so + // render a loading state rather than nothing at all. + emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) } is NavTarget.SessionVerification -> { val callback = object : FtueSessionVerificationFlowNode.Callback { diff --git a/libraries/ui-common/build.gradle.kts b/libraries/ui-common/build.gradle.kts index 76c772f9fc..440892e90d 100644 --- a/libraries/ui-common/build.gradle.kts +++ b/libraries/ui-common/build.gradle.kts @@ -17,4 +17,5 @@ android { dependencies { implementation(libs.appyx.core) implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt index 53cc8c8443..7d9e851e89 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -9,37 +9,84 @@ package io.element.android.libraries.ui.common.nodes import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.DelayedVisibility +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 + * + * @param buildContext the [BuildContext] of the Node. + * @param delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for + * placeholders which can stay on screen for a long time, or forever, else the application looks frozen. + * Defaults to [Duration.INFINITE], i.e. the Node stays empty. */ fun emptyNode( buildContext: BuildContext, + delayBeforeShowingContent: Duration = Duration.INFINITE, ): Node = node(buildContext) { modifier -> - EmptyView(modifier) + EmptyView( + delayBeforeShowingContent = delayBeforeShowingContent, + modifier = modifier, + ) } @Composable private fun EmptyView( + delayBeforeShowingContent: Duration, modifier: Modifier = Modifier, ) = Box( modifier = modifier .fillMaxSize() .background(ElementTheme.colors.bgCanvasDefault), -) + contentAlignment = Alignment.Center, +) { + if (delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = delayBeforeShowingContent) { + LoadingContent() + } + } +} + +@Composable +private fun LoadingContent() = Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), +) { + CircularProgressIndicator() + Text( + text = stringResource(CommonStrings.common_loading), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) +} @PreviewsDayNight @Composable internal fun EmptyViewPreview() = ElementPreview { - EmptyView(Modifier) + EmptyView(delayBeforeShowingContent = Duration.INFINITE) +} + +@PreviewsDayNight +@Composable +internal fun EmptyViewLoadingPreview() = ElementPreview { + EmptyView(delayBeforeShowingContent = 1.seconds) } From d59120aad0bd9fa32a31f75d2a50f3c3776868ea Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Sep 2026 12:48:58 +0200 Subject: [PATCH 2/7] Restart the sync service when it stops on an error Synapse deletes sliding sync connections that have been unused for 7 days. When an OEM battery manager has kept the application frozen for longer than that, the next sliding sync request is answered 400 M_UNKNOWN_POS. The Rust SDK handles that: it resets `pos` and, in offline mode, restarts the sync. When that recovery gives up, the sync service ends up in the Error or Terminated state and stops for good, and it is then up to the application to call start() again. Element X iOS does it, we do not: SyncOrchestrator only restarts the sync service when its state is Idle, so the sync stays dead for the rest of the process lifetime. Nothing recovers from there. The sync never reaches Running, so waitForE2eeInitializationTasks() never returns, the session verified status stays Unknown, and the FTUE is stuck on WaitingForInitialState. Clearing the application data is the only workaround. Restart the sync service when it is in the Error or Terminated state, with an exponential backoff from 1s to 30s to avoid hammering a failing homeserver. The backoff is only reset once the sync service has been running for a while, since startSync() moves the state to Running right away. Fixes #7689 Co-Authored-By: Claude Opus 5 (1M context) --- .../appnav/session/SyncOrchestrator.kt | 68 +++++++- .../android/appnav/SyncOrchestratorTest.kt | 147 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 3a72c10ab7..786365b7be 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -60,6 +64,12 @@ class SyncOrchestrator( private val started = AtomicBoolean(false) + /** + * Number of consecutive attempts at restarting a sync service which stopped on its own. + * Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY]. + */ + private val restartAttempt = AtomicInteger(0) + /** * Starting observing the app state and network state to start/stop the sync service. * @@ -91,6 +101,17 @@ class SyncOrchestrator( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun observeStates() = coroutineScope.launch { Timber.tag(tag).d("start observing the app and network state") + // Reset the restart backoff once the sync service has been steadily running for a while. Note that + // SyncService.startSync() moves the state to Running right away, so resetting on any Running value + // would defeat the backoff when the sync service dies again immediately after each restart. + syncService.syncState + .debounce(RESTART_BACKOFF_RESET_DELAY) + .onEach { syncState -> + if (syncState == SyncState.Running) { + restartAttempt.set(0) + } + } + .launchIn(this) val isAppActiveFlows = listOf( appForegroundStateService.isInForeground, appForegroundStateService.isInCall, @@ -117,14 +138,26 @@ class SyncOrchestrator( SyncStateAction.StopSync } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) { SyncStateAction.StartSync + } else if (syncState.isDead() && isAppActive && isNetworkAvailable) { + // The sync service has given up, for instance because the homeserver has expired our sliding sync + // session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here, + // else the application is stuck until its data is cleared. + SyncStateAction.RestartSync } else { SyncStateAction.NoOp } } .distinctUntilChanged() .debounce { action -> - // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often - if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds + when (action) { + // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often + SyncStateAction.StopSync -> 3.seconds + // Back off before restarting a dead sync service, to avoid hammering a failing homeserver. + // A state change in the meantime cancels the pending restart. + SyncStateAction.RestartSync -> restartDelay(restartAttempt.get()) + SyncStateAction.StartSync, + SyncStateAction.NoOp -> 0.seconds + } } .onCompletion { Timber.tag(tag).d("has been stopped") @@ -134,6 +167,11 @@ class SyncOrchestrator( SyncStateAction.StartSync -> { syncService.startSync() } + SyncStateAction.RestartSync -> { + val attempt = restartAttempt.incrementAndGet() + Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)") + syncService.startSync() + } SyncStateAction.StopSync -> { syncService.stopSync() } @@ -143,8 +181,34 @@ class SyncOrchestrator( } } +@VisibleForTesting +internal val FIRST_RESTART_DELAY = 1.seconds + +@VisibleForTesting +internal val MAX_RESTART_DELAY = 30.seconds + +/** + * Duration during which the sync service has to stay running before the restart backoff is reset. + */ +@VisibleForTesting +internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds + +/** + * Return true if the sync service has stopped and will not restart by itself. + */ +private fun SyncState.isDead() = this == SyncState.Error || this == SyncState.Terminated + +/** + * Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [MAX_RESTART_DELAY]. + */ +private fun restartDelay(attempt: Int): Duration { + val exponent = attempt.coerceIn(0, 5) + return (FIRST_RESTART_DELAY * (1 shl exponent)).coerceAtMost(MAX_RESTART_DELAY) +} + private enum class SyncStateAction { StartSync, + RestartSync, StopSync, NoOp, } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 32d0ec8f56..9475399a85 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -8,6 +8,9 @@ package io.element.android.appnav +import io.element.android.appnav.session.FIRST_RESTART_DELAY +import io.element.android.appnav.session.MAX_RESTART_DELAY +import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor @@ -414,6 +417,150 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isNeverCalled() } + @Test + fun `when the sync service is in error, it will be restarted after a delay`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + // The restart does not happen right away, we back off first + advanceTimeBy(FIRST_RESTART_DELAY / 2) + startSyncRecorder.assertions().isNeverCalled() + + // But it does happen once the backoff has elapsed + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the sync service is terminated, it will be restarted after a delay`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Terminated).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(FIRST_RESTART_DELAY * 2) + startSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(FIRST_RESTART_DELAY * 2) + startSyncRecorder.assertions().isCalledOnce() + + // The sync service starts, but dies again straight away + syncService.emitSyncState(SyncState.Running) + advanceTimeBy(200.milliseconds) + syncService.emitSyncState(SyncState.Error) + + // The second restart is not attempted after the first delay anymore + advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // But after twice that delay + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledExactly(2) + } + + @Test + fun `when the sync service recovers, the delay between the restarts is reset`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(FIRST_RESTART_DELAY * 2) + startSyncRecorder.assertions().isCalledOnce() + + // The sync service is now running, and keeps running for a while + syncService.emitSyncState(SyncState.Running) + advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds) + startSyncRecorder.assertions().isCalledOnce() + + // It dies again, and is restarted after the first delay, not a longer one + syncService.emitSyncState(SyncState.Error) + advanceTimeBy(FIRST_RESTART_DELAY / 2) + startSyncRecorder.assertions().isCalledOnce() + advanceTimeBy(FIRST_RESTART_DELAY) + startSyncRecorder.assertions().isCalledExactly(2) + } + + @Test + fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(MAX_RESTART_DELAY * 2) + startSyncRecorder.assertions().isNeverCalled() + } + + @Test + fun `when the sync service is in error but there is no network, it is not restarted`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService), + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected), + appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), + ) + + // We start observing + syncOrchestrator.observeStates() + + advanceTimeBy(MAX_RESTART_DELAY * 2) + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), From c643acda387bc96d2650760a0d2ceb9d9af304cb Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 11 Sep 2026 11:08:34 +0000 Subject: [PATCH 3/7] Update screenshots --- .../libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png | 3 +++ .../libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png new file mode 100644 index 0000000000..0db7c3fe4a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f696c5c13677bc4e08602fd54b150b14a738bdcdd3cff2fd77c0634006505b03 +size 6793 diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png new file mode 100644 index 0000000000..172188ba64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:883108c96ef47f0fb8ec6f8160386c880f198a36212555614c734983a97cd667 +size 6787 From e7372c7f49552bbe905b94e3117ade78460a100d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Sep 2026 16:42:22 +0200 Subject: [PATCH 4/7] Use PreviewParameterProvider --- .../ui/common/nodes/DurationPreviewParam.kt | 20 ++++++ .../libraries/ui/common/nodes/EmptyNode.kt | 60 ---------------- .../libraries/ui/common/nodes/EmptyView.kt | 68 +++++++++++++++++++ 3 files changed, 88 insertions(+), 60 deletions(-) create mode 100644 libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt create mode 100644 libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt new file mode 100644 index 0000000000..4cf621c51c --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +open class DurationPreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + Duration.INFINITE, + 1.seconds, + ) +} diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt index 7d9e851e89..5e8131f23d 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -8,28 +8,10 @@ package io.element.android.libraries.ui.common.nodes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.DelayedVisibility -import io.element.android.libraries.ui.strings.CommonStrings import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 @@ -48,45 +30,3 @@ fun emptyNode( modifier = modifier, ) } - -@Composable -private fun EmptyView( - delayBeforeShowingContent: Duration, - modifier: Modifier = Modifier, -) = Box( - modifier = modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault), - contentAlignment = Alignment.Center, -) { - if (delayBeforeShowingContent.isFinite()) { - DelayedVisibility(duration = delayBeforeShowingContent) { - LoadingContent() - } - } -} - -@Composable -private fun LoadingContent() = Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), -) { - CircularProgressIndicator() - Text( - text = stringResource(CommonStrings.common_loading), - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodyMdRegular, - ) -} - -@PreviewsDayNight -@Composable -internal fun EmptyViewPreview() = ElementPreview { - EmptyView(delayBeforeShowingContent = Duration.INFINITE) -} - -@PreviewsDayNight -@Composable -internal fun EmptyViewLoadingPreview() = ElementPreview { - EmptyView(delayBeforeShowingContent = 1.seconds) -} diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt new file mode 100644 index 0000000000..f9979790f2 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.DelayedVisibility +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.time.Duration + +@Composable +fun EmptyView( + delayBeforeShowingContent: Duration, + modifier: Modifier = Modifier, +) = Box( + modifier = modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, +) { + if (delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = delayBeforeShowingContent) { + LoadingContent() + } + } +} + +@Composable +private fun LoadingContent() = Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), +) { + CircularProgressIndicator() + Text( + text = stringResource(CommonStrings.common_loading), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) +} + +@PreviewsDayNight +@Composable +internal fun EmptyViewPreview( + @PreviewParameter(DurationPreviewParam::class) duration: Duration +) = ElementPreview { + EmptyView(delayBeforeShowingContent = duration) +} From b28b2ceeed1ea9edef2e7628544552a186a44450 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 11 Sep 2026 15:04:02 +0000 Subject: [PATCH 5/7] Update screenshots --- ...braries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png} | 0 ...braries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png} | 0 ...aries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png} | 0 ...aries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyView_Day_0_en.png => libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png => libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyView_Night_0_en.png => libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png => libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png} (100%) diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewLoading_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png From 25505d6e8aaeb1e5dddd3e32e5b4afe28317126b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 15 Sep 2026 10:00:43 +0200 Subject: [PATCH 6/7] Do not automatically restart the sync in case it is Terminated. Also simplify the computation of the backoff delay. --- .../appnav/session/SyncOrchestrator.kt | 25 ++++++++++--------- .../android/appnav/SyncOrchestratorTest.kt | 25 +++---------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 786365b7be..9863f75410 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -138,7 +138,7 @@ class SyncOrchestrator( SyncStateAction.StopSync } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) { SyncStateAction.StartSync - } else if (syncState.isDead() && isAppActive && isNetworkAvailable) { + } else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) { // The sync service has given up, for instance because the homeserver has expired our sliding sync // session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here, // else the application is stuck until its data is cleared. @@ -184,8 +184,18 @@ class SyncOrchestrator( @VisibleForTesting internal val FIRST_RESTART_DELAY = 1.seconds +/** + * Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32]. + */ @VisibleForTesting -internal val MAX_RESTART_DELAY = 30.seconds +internal val RESTART_BACKOFF_DELAYS = listOf( + FIRST_RESTART_DELAY, + FIRST_RESTART_DELAY * 2, + FIRST_RESTART_DELAY * 4, + FIRST_RESTART_DELAY * 8, + FIRST_RESTART_DELAY * 16, + FIRST_RESTART_DELAY * 32, +) /** * Duration during which the sync service has to stay running before the restart backoff is reset. @@ -193,17 +203,8 @@ internal val MAX_RESTART_DELAY = 30.seconds @VisibleForTesting internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds -/** - * Return true if the sync service has stopped and will not restart by itself. - */ -private fun SyncState.isDead() = this == SyncState.Error || this == SyncState.Terminated - -/** - * Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [MAX_RESTART_DELAY]. - */ private fun restartDelay(attempt: Int): Duration { - val exponent = attempt.coerceIn(0, 5) - return (FIRST_RESTART_DELAY * (1 shl exponent)).coerceAtMost(MAX_RESTART_DELAY) + return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)] } private enum class SyncStateAction { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 9475399a85..af53c68b3d 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -9,7 +9,7 @@ package io.element.android.appnav import io.element.android.appnav.session.FIRST_RESTART_DELAY -import io.element.android.appnav.session.MAX_RESTART_DELAY +import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus @@ -441,25 +441,6 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isCalledOnce() } - @Test - fun `when the sync service is terminated, it will be restarted after a delay`() = runTest { - val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } - val syncService = FakeSyncService(initialSyncState = SyncState.Terminated).apply { - startSyncLambda = startSyncRecorder - } - val syncOrchestrator = createSyncOrchestrator( - matrixClient = FakeMatrixClient(syncService = syncService), - networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), - appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true), - ) - - // We start observing - syncOrchestrator.observeStates() - - advanceTimeBy(FIRST_RESTART_DELAY * 2) - startSyncRecorder.assertions().isCalledOnce() - } - @Test fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest { val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } @@ -538,7 +519,7 @@ class SyncOrchestratorTest { // We start observing syncOrchestrator.observeStates() - advanceTimeBy(MAX_RESTART_DELAY * 2) + advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2) startSyncRecorder.assertions().isNeverCalled() } @@ -557,7 +538,7 @@ class SyncOrchestratorTest { // We start observing syncOrchestrator.observeStates() - advanceTimeBy(MAX_RESTART_DELAY * 2) + advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2) startSyncRecorder.assertions().isNeverCalled() } From c512e3f299b31d7c8cfe254f66a543ddd76cb79d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 15 Sep 2026 10:52:45 +0200 Subject: [PATCH 7/7] Introduce an EmptyState, so that screenshot filename stays consistent. --- .../element/android/appnav/LoggedInFlowNode.kt | 3 ++- .../android/features/ftue/impl/FtueFlowNode.kt | 3 ++- .../libraries/ui/common/nodes/EmptyNode.kt | 8 +++----- .../libraries/ui/common/nodes/EmptyState.kt | 18 ++++++++++++++++++ ...eviewParam.kt => EmptyStatePreviewParam.kt} | 8 ++++---- .../libraries/ui/common/nodes/EmptyView.kt | 11 +++++------ ...ies.ui.common.nodes_EmptyView_Day_0_en.png} | 0 ...ies.ui.common.nodes_EmptyView_Day_1_en.png} | 0 ...s.ui.common.nodes_EmptyView_Night_0_en.png} | 0 ...s.ui.common.nodes_EmptyView_Night_1_en.png} | 0 10 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt rename libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/{DurationPreviewParam.kt => EmptyStatePreviewParam.kt} (68%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png => libraries.ui.common.nodes_EmptyView_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png => libraries.ui.common.nodes_EmptyView_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png => libraries.ui.common.nodes_EmptyView_Night_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png => libraries.ui.common.nodes_EmptyView_Night_1_en.png} (100%) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4254d66664..a0bcf3b489 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.ui.common.nodes.EmptyState import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService @@ -327,7 +328,7 @@ class LoggedInFlowNode( return when (navTarget) { // Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all, // since there is no guarantee that this state will be left. - NavTarget.Placeholder -> emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) + NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds)) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index b3d96a88e5..461fc4ea80 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.ui.common.nodes.EmptyState import io.element.android.libraries.ui.common.nodes.emptyNode import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -88,7 +89,7 @@ class FtueFlowNode( // Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState). // This can last for a while, or forever if the sync service never manages to run, so // render a loading state rather than nothing at all. - emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) + emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds)) } is NavTarget.SessionVerification -> { val callback = object : FtueSessionVerificationFlowNode.Callback { diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt index 5e8131f23d..1e7e1b8d21 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -17,16 +17,14 @@ import kotlin.time.Duration * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 * * @param buildContext the [BuildContext] of the Node. - * @param delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for - * placeholders which can stay on screen for a long time, or forever, else the application looks frozen. - * Defaults to [Duration.INFINITE], i.e. the Node stays empty. + * @param state the [EmptyState] of the Node. Default value will render an empty node forever. */ fun emptyNode( buildContext: BuildContext, - delayBeforeShowingContent: Duration = Duration.INFINITE, + state: EmptyState = EmptyState(Duration.INFINITE), ): Node = node(buildContext) { modifier -> EmptyView( - delayBeforeShowingContent = delayBeforeShowingContent, + state = state, modifier = modifier, ) } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt new file mode 100644 index 0000000000..0930523881 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import kotlin.time.Duration + +/** + * @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for + * placeholders which can stay on screen for a long time, or forever, else the application looks frozen. + */ +data class EmptyState( + val delayBeforeShowingContent: Duration, +) diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt similarity index 68% rename from libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt rename to libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt index 4cf621c51c..c0f1705276 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/DurationPreviewParam.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt @@ -11,10 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -open class DurationPreviewParam : PreviewParameterProvider { - override val values: Sequence +open class EmptyStatePreviewParam : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - Duration.INFINITE, - 1.seconds, + EmptyState(Duration.INFINITE), + EmptyState(1.seconds), ) } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt index f9979790f2..77ff6555a5 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt @@ -26,11 +26,10 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.DelayedVisibility import io.element.android.libraries.ui.strings.CommonStrings -import kotlin.time.Duration @Composable fun EmptyView( - delayBeforeShowingContent: Duration, + state: EmptyState, modifier: Modifier = Modifier, ) = Box( modifier = modifier @@ -38,8 +37,8 @@ fun EmptyView( .background(ElementTheme.colors.bgCanvasDefault), contentAlignment = Alignment.Center, ) { - if (delayBeforeShowingContent.isFinite()) { - DelayedVisibility(duration = delayBeforeShowingContent) { + if (state.delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = state.delayBeforeShowingContent) { LoadingContent() } } @@ -62,7 +61,7 @@ private fun LoadingContent() = Column( @PreviewsDayNight @Composable internal fun EmptyViewPreview( - @PreviewParameter(DurationPreviewParam::class) duration: Duration + @PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState ) = ElementPreview { - EmptyView(delayBeforeShowingContent = duration) + EmptyView(state = state) } diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Day_1_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyViewPreview-KLykuaI_Night_1_en.png rename to tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_1_en.png