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..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 @@ -113,6 +114,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 +326,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, state = EmptyState(delayBeforeShowingContent = 500.milliseconds)) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { 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..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 @@ -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 == 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. + 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,35 @@ 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 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. + */ +@VisibleForTesting +internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds + +private fun restartDelay(attempt: Int): Duration { + return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)] +} + 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..af53c68b3d 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.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 import io.element.android.features.networkmonitor.test.FakeNetworkMonitor @@ -414,6 +417,131 @@ 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 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(RESTART_BACKOFF_DELAYS.last() * 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(RESTART_BACKOFF_DELAYS.last() * 2) + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), 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..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,11 +32,13 @@ 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 import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.milliseconds @ContributesNode(SessionScope::class) @AssistedInject @@ -84,7 +86,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, EmptyState(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 719487083d..9487adbcf1 100644 --- a/libraries/ui-common/build.gradle.kts +++ b/libraries/ui-common/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { implementation(libs.appyx.core) implementation(projects.libraries.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..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 @@ -8,38 +8,23 @@ package io.element.android.libraries.ui.common.nodes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier 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 kotlin.time.Duration /** * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 + * + * @param buildContext the [BuildContext] of the Node. + * @param state the [EmptyState] of the Node. Default value will render an empty node forever. */ fun emptyNode( buildContext: BuildContext, + state: EmptyState = EmptyState(Duration.INFINITE), ): Node = node(buildContext) { modifier -> - EmptyView(modifier) -} - -@Composable -private fun EmptyView( - modifier: Modifier = Modifier, -) = Box( - modifier = modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault), -) - -@PreviewsDayNight -@Composable -internal fun EmptyViewPreview() = ElementPreview { - EmptyView(Modifier) + EmptyView( + 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/EmptyStatePreviewParam.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.kt new file mode 100644 index 0000000000..c0f1705276 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyStatePreviewParam.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 EmptyStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + 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 new file mode 100644 index 0000000000..77ff6555a5 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyView.kt @@ -0,0 +1,67 @@ +/* + * 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 + +@Composable +fun EmptyView( + state: EmptyState, + modifier: Modifier = Modifier, +) = Box( + modifier = modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, +) { + if (state.delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = state.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(EmptyStatePreviewParam::class) state: EmptyState +) = ElementPreview { + EmptyView(state = state) +} diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_1_en.png new file mode 100644 index 0000000000..0db7c3fe4a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_1_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_EmptyView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_1_en.png new file mode 100644 index 0000000000..172188ba64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:883108c96ef47f0fb8ec6f8160386c880f198a36212555614c734983a97cd667 +size 6787