Merge pull request #7692 from element-hq/bma/fix7689

Fix blank screen at launch when the homeserver expires the sliding sync session
This commit is contained in:
Benoit Marty
2026-09-23 12:32:00 +02:00
committed by GitHub
11 changed files with 327 additions and 28 deletions
@@ -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() {
@@ -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,
}
@@ -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<Unit>> { 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<Unit>> { 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<Unit>> { 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<Unit>> { 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<Unit>> { 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(),
@@ -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 {
+1
View File
@@ -18,4 +18,5 @@ dependencies {
implementation(libs.appyx.core)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}
@@ -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,
)
}
@@ -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,
)
@@ -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<EmptyState> {
override val values: Sequence<EmptyState>
get() = sequenceOf(
EmptyState(Duration.INFINITE),
EmptyState(1.seconds),
)
}
@@ -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)
}