Say when the server cannot be reached instead of showing nothing

While the homeserver is unreachable but the device still has a working
network, the app showed no indication at all: the syncing chip is hidden
as soon as the SDK reports the session offline, and the offline banner
only appears when the device itself has no connectivity. A blocked or
down server was therefore indistinguishable from a quiet, healthy one.

The chip now has three states rather than a boolean, and the third says
"Server unreachable" for exactly that case. When the device has no
network the chip stays hidden, because the offline banner already says so
and two overlapping messages would be worse than one.

Part of #4099
This commit is contained in:
hayaksi1
2026-08-25 20:51:27 +03:00
parent a2d3488426
commit 30c18b8a00
6 changed files with 72 additions and 18 deletions
@@ -105,9 +105,18 @@ class LoggedInPresenter(
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
val hasNetwork by networkMonitor.connectivity.collectAsState()
val syncIndicatorState by remember {
derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
when {
isOnline -> if (syncIndicator == RoomListService.SyncIndicator.Show) {
SyncIndicatorState.Syncing
} else {
SyncIndicatorState.Hidden
}
hasNetwork == NetworkStatus.Connected -> SyncIndicatorState.ServerUnreachable
else -> SyncIndicatorState.Hidden
}
}
}
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
@@ -181,7 +190,7 @@ class LoggedInPresenter(
}
return LoggedInState(
showSyncSpinner = showSyncSpinner,
syncIndicatorState = syncIndicatorState,
pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
@@ -11,8 +11,14 @@ package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
enum class SyncIndicatorState {
Hidden,
Syncing,
ServerUnreachable,
}
data class LoggedInState(
val showSyncSpinner: Boolean,
val syncIndicatorState: SyncIndicatorState,
val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
@@ -17,7 +17,8 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
aLoggedInState(showSyncSpinner = true),
aLoggedInState(syncIndicatorState = SyncIndicatorState.Syncing),
aLoggedInState(syncIndicatorState = SyncIndicatorState.ServerUnreachable),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
@@ -26,13 +27,13 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
}
fun aLoggedInState(
showSyncSpinner: Boolean = false,
syncIndicatorState: SyncIndicatorState = SyncIndicatorState.Hidden,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X",
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
syncIndicatorState = syncIndicatorState,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
@@ -47,7 +47,7 @@ fun LoggedInView(
) {
SyncStateView(
modifier = Modifier.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
state = state.syncIndicatorState,
)
}
when (state.pusherRegistrationState) {
@@ -25,18 +25,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SyncStateView(
isVisible: Boolean,
state: SyncIndicatorState,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
visible = state != SyncIndicatorState.Hidden,
modifier = modifier,
enter = fadeIn(spring(stiffness = 500F)),
exit = fadeOut(spring(stiffness = 500F)),
) {
AsyncIndicator.Loading(
text = stringResource(id = CommonStrings.common_syncing),
)
when (state) {
SyncIndicatorState.ServerUnreachable -> AsyncIndicator.Failure(
text = stringResource(id = CommonStrings.common_server_unreachable),
)
else -> AsyncIndicator.Loading(
text = stringResource(id = CommonStrings.common_syncing),
)
}
}
}
@@ -46,7 +51,18 @@ internal fun SyncStateViewPreview() = ElementPreview {
// Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) {
SyncStateView(
isVisible = true
state = SyncIndicatorState.Syncing
)
}
}
@PreviewsDayNight
@Composable
internal fun SyncStateViewServerUnreachablePreview() = ElementPreview {
// Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) {
SyncStateView(
state = SyncIndicatorState.ServerUnreachable
)
}
}
@@ -67,7 +67,7 @@ class LoggedInPresenterTest {
fun `present - initial state`() = runTest {
createLoggedInPresenter().test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(initialState.ignoreRegistrationError).isFalse()
}
@@ -97,11 +97,33 @@ class LoggedInPresenterTest {
matrixClient = FakeMatrixClient(roomListService = roomListService),
).test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
consumeItemsUntilPredicate { it.showSyncSpinner }
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Syncing }
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide)
consumeItemsUntilPredicate { !it.showSyncSpinner }
consumeItemsUntilPredicate { it.syncIndicatorState != SyncIndicatorState.Syncing }
}
}
@Test
fun `present - say the server is unreachable when the device has network but the sync is offline`() = runTest {
createLoggedInPresenter(
syncState = SyncState.Offline,
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
).test {
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.ServerUnreachable }
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - say nothing when the device itself has no network`() = runTest {
createLoggedInPresenter(
syncState = SyncState.Offline,
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
).test {
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Hidden }
cancelAndConsumeRemainingEvents()
}
}