diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index ac08795f67..7c944764fd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings @@ -34,7 +34,7 @@ class LoggedInEventProcessor( @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, private val sessionId: SessionId, - private val roomListService: RoomListService, + private val syncService: SyncService, private val notificationCleaner: NotificationCleaner, private val dispatchers: CoroutineDispatchers ) { @@ -73,9 +73,8 @@ class LoggedInEventProcessor( } .launchIn(childScope) - // Use the room list service state as a 'heartbeat' update to check existing notifications - roomListService.state - .filter { it == RoomListService.State.Running } + // Use the sync service heartbeat as a 'heartbeat' update to check existing notifications + syncService.heartBeat .onEach { notificationCleaner.clearReadRoomsNotifications(sessionId) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt index 4d5cfe06f3..fd1f1b9d11 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -155,24 +154,24 @@ class LoggedInEventProcessorTest { } @Test - fun `test notifications are cleared when room list service state is running`() = runTest { + fun `test notifications are cleared when sync heartbeat is emitted`() = runTest { val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } val snackbarDispatcher = SnackbarDispatcher() val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) - val roomListService = FakeRoomListService() + val syncService = FakeSyncService() val loggedInEventProcessor = createLoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, notificationCleaner = notificationCleaner, - roomListService = roomListService, + syncService = syncService, ) loggedInEventProcessor.observeEvents(backgroundScope) testScheduler.runCurrent() - // Simulate the room list service state changing to Running - roomListService.postState(RoomListService.State.Running) + // Simulate a sync heartbeat + syncService.emitHeartBeat() testScheduler.runCurrent() @@ -184,13 +183,13 @@ class LoggedInEventProcessorTest { snackbarDispatcher: SnackbarDispatcher, roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), sessionId: SessionId = A_SESSION_ID, - roomListService: FakeRoomListService = FakeRoomListService(), + syncService: FakeSyncService = FakeSyncService(), notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), ) = LoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, roomMembershipObserver = roomMembershipObserver, sessionId = sessionId, - roomListService = roomListService, + syncService = syncService, notificationCleaner = notificationCleaner, dispatchers = testCoroutineDispatchers(), ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95c98a4aec..95f6c229b8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds @Inject class HomePresenter( @@ -53,10 +59,20 @@ class HomePresenter( ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable override fun present(): HomeState { val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() + + val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { + syncService.heartBeat + // Delay the heartbeat so the SDK has time to update the unread count before we read it. + .debounce(100.milliseconds) + .mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() } + .collect { value = it } + } + val currentUserAndNeighbors by remember { combine( client.userProfile, @@ -130,6 +146,7 @@ class HomePresenter( snackbarMessage = snackbarMessage, canReportBug = canReportBug, showMultiAccountAnnouncement = showMultiAccountAnnouncement, + totalUnreadCount = totalUnreadCount, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index a49d3876e9..eb8e3ff2f1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -29,6 +29,7 @@ data class HomeState( val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val showMultiAccountAnnouncement: Boolean, + val totalUnreadCount: Int, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f86c32c532..4a82166d3e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -64,6 +64,7 @@ internal fun aHomeState( homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, showMultiAccountAnnouncement: Boolean = false, + totalUnreadCount: Int = 0, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -75,5 +76,6 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + totalUnreadCount = totalUnreadCount, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6ff31a7c12..c32ed981c0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -259,6 +259,7 @@ private fun HomeScaffold( } } }, + totalUnreadCount = state.totalUnreadCount, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -341,6 +342,7 @@ private fun HomeFloatingActionButton( @Composable private fun HomeBottomBar( + totalUnreadCount: Int, currentHomeNavigationBarItem: HomeNavigationBarItem, onItemClick: (HomeNavigationBarItem) -> Unit, modifier: Modifier = Modifier, @@ -361,6 +363,7 @@ private fun HomeBottomBar( tooltipLabel = stringResource(item.labelRes), isSelected = isSelected, onClick = { onItemClick(item) }, + counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 } ) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index cf592d9e7e..fa0d40017e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -40,9 +40,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds class HomePresenterTest { @get:Rule @@ -117,6 +119,38 @@ class HomePresenterTest { } } + @Test + fun `present - total unread count is updated on sync heartbeat`() = runTest { + var unreadCount = 0L + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + unreadCount = 5 + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } + + @Test + fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + expectNoEvents() + } + } + @Test fun `present - should start with no user and then load user with error`() = runTest { val matrixClient = FakeMatrixClient( @@ -245,6 +279,16 @@ class HomePresenterTest { .with(value(Announcement.MultiAccount)) } } + + @Test + fun `present - total unread count is loaded in the initial state`() = runTest { + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }), + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } } internal fun createHomePresenter( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 3b4fd40885..71342d1b51 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher { /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ fun homeserverCapabilities(): HomeserverCapabilitiesProvider + + /** + * Returns the total number of unread notifications across all rooms, as currently known by the client. + * This is a snapshot and may be out of date if the sync is running. + */ + fun totalUnreadCount(): Result } /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt index 92f60352d7..25fd1475f1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.sync +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -37,4 +38,10 @@ interface SyncService { * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. */ val isOnline: StateFlow + + /** + * Flow that will emit every time the sync has received a response from the server, even if it was empty. + * Useful for triggering UI updates when the sync is running. + */ + val heartBeat: Flow } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 74180e4e0b..dcf75b78bc 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -1024,6 +1024,10 @@ class RustMatrixClient( return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + override fun totalUnreadCount(): Result = runCatchingExceptions { + innerClient.totalUnreadNotifications().toLong() + } + private fun scheduleBackgroundSearchBackfill() { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) .distinctUntilChanged() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index f7732cd9b7..d08e561fb5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.impl.roomlist.stateFlow import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceState import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -78,4 +82,10 @@ class RustSyncService( .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + + // TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service + override val heartBeat: Flow = inner.roomListService() + .stateFlow() + .filter { it == RoomListServiceState.RUNNING } + .map {} } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6d45db5041..b864daefcc 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,7 @@ class FakeMatrixClient( override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), + private val totalUnreadCountResult: () -> Result = { Result.success(0L) }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( @@ -468,4 +469,8 @@ class FakeMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return homeserverCapabilitiesProvider } + + override fun totalUnreadCount(): Result { + return totalUnreadCountResult() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index 5bb85fa488..174ec4ce62 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.test.sync import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,6 +35,14 @@ class FakeSyncService( override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + private val heartBeatFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override val heartBeat: Flow = heartBeatFlow + + suspend fun emitHeartBeat() { + heartBeatFlow.emit(Unit) + } + suspend fun emitSyncState(syncState: SyncState) { syncStateFlow.emit(syncState) }