diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt index 81e935e602..e07c8caeab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt @@ -66,7 +66,10 @@ class CommonTopAppBarViewModel @Inject constructor( coreLogic.get().sessionScope(userId) { combine(observeSyncState(), coreLogic.get().networkStateObserver.observeNetworkState()) { syncState, networkState -> when (syncState) { - is Waiting -> Connectivity.WaitingConnection(null, null) + // Waiting is a pure pre-initialization state: the sync worker has not been + // scheduled yet. It carries no information about network health, so map it + // to Idle (no banner) rather than WaitingConnection or Connecting. + is Waiting -> Connectivity.Idle is Failed -> Connectivity.WaitingConnection(syncState.cause, syncState.retryDelay) is GatheringPendingEvents, is SlowSync -> Connectivity.Connecting @@ -79,6 +82,18 @@ class CommonTopAppBarViewModel @Inject constructor( } } } + }.debounce { connectivity -> + when (connectivity) { + // Pass through immediately so the banner is dismissed without delay + // once sync finishes, and any pending debounce timer in the per-session + // debounce below is canceled before it can show a stale banner. + Connectivity.Connected, + Connectivity.Idle -> 0L + // Hold Connecting / WaitingConnection for the full debounce window. + // If sync or network recovers within that window the timer is canceled + // and no banner is ever shown. + else -> CONNECTIVITY_STATE_DEBOUNCE_DEFAULT + } } @VisibleForTesting @@ -112,25 +127,24 @@ class CommonTopAppBarViewModel @Inject constructor( connectivityFlow(userId), ) { activeCalls, currentScreen, connectivity -> mapToConnectivityUIState(currentScreen, connectivity, userId, activeCalls) + }.debounce { state -> + // Scoped inside flatMapLatest so this debounce is canceled + // together with the inner flow on session change, preventing + // stale state from leaking into a new session. + when { + // Delay the ongoing-call bar slightly to absorb rapid + // mute/unmute state changes without flickering. + state is ConnectivityUIState.Calls && state.hasOngoingCall -> + CONNECTIVITY_STATE_DEBOUNCE_ONGOING_CALL + // Everything else (connectivity banners, incoming/outgoing + // calls, None) passes through immediately. Connectivity + // states are already debounced inside connectivityFlow. + else -> 0L + } } } } } - .debounce { state -> - /** - * Adding some debounce here to avoid some bad UX and prevent from having blinking effect when the state changes - * quickly, e.g. when displaying ongoing call banner and hiding it in a short time when the user hangs up the call. - * Call events could take some time to be received and this function could be called when the screen is changed, - * so we delayed showing the banner until getting the correct calling values and for calls this debounce is bigger - * than for other states in order to allow for the correct handling of hanging up a call. - * When state changes to None, handle it immediately, that's why we return 0L debounce time in this case. - */ - when { - state is ConnectivityUIState.None -> 0L - state is ConnectivityUIState.Calls && state.hasOngoingCall -> CONNECTIVITY_STATE_DEBOUNCE_ONGOING_CALL - else -> CONNECTIVITY_STATE_DEBOUNCE_DEFAULT - } - } .collectLatest { connectivityUIState -> state = state.copy(connectivityState = connectivityUIState) } @@ -168,7 +182,8 @@ class CommonTopAppBarViewModel @Inject constructor( return if (canDisplayConnectivityIssues) { when (connectivity) { Connectivity.Connecting -> ConnectivityUIState.Connecting - Connectivity.Connected -> ConnectivityUIState.None + Connectivity.Connected, + Connectivity.Idle -> ConnectivityUIState.None is Connectivity.WaitingConnection -> ConnectivityUIState.WaitingConnection( connectivity.cause, connectivity.retryDelay, @@ -181,6 +196,6 @@ class CommonTopAppBarViewModel @Inject constructor( private companion object { const val CONNECTIVITY_STATE_DEBOUNCE_ONGOING_CALL = 600L - const val CONNECTIVITY_STATE_DEBOUNCE_DEFAULT = 200L + const val CONNECTIVITY_STATE_DEBOUNCE_DEFAULT = 1000L } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/Connectivity.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/Connectivity.kt index bc229c12df..0f2e1ea6e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/Connectivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/Connectivity.kt @@ -25,4 +25,5 @@ sealed interface Connectivity { data class WaitingConnection(val cause: CoreFailure?, val retryDelay: Duration?) : Connectivity data object Connecting : Connectivity data object Connected : Connectivity + data object Idle : Connectivity } diff --git a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt index 96866f1ed8..53ab6a4c56 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt @@ -96,6 +96,25 @@ class CommonTopAppBarViewModelTest { info shouldBeInstanceOf ConnectivityUIState.Connecting::class } + @Test + fun givenNoCallAndHomeScreenAndWaiting_whenGettingState_thenShouldHaveNoneInfo() = + runTest { + val (_, commonTopAppBarViewModel) = Arrangement() + .withCurrentSessionExist() + .withoutOngoingCall() + .withoutOutgoingCall() + .withoutIncomingCall() + .withCurrentScreen(CurrentScreen.Home) + .withSyncState(SyncState.Waiting) + .arrange() + + advanceUntilIdle() + val state = commonTopAppBarViewModel.state + + val info = state.connectivityState + info shouldBeInstanceOf ConnectivityUIState.None::class + } + @Test fun givenAnOngoingCallAndHomeScreenAndConnectivityIssues_whenGettingState_thenShouldHaveActiveCallInfo() = runTest {