Add MatrixClient.totalUnreadCountFlow() extension

Extract the logic to observe the total unread count of a MatrixClient, refreshed on each sync
heartbeat, to a shared extension and use it in HomePresenter and PreferencesRootPresenter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 14:31:31 +02:00
parent b37f1d5b71
commit 095fda3d9e
4 changed files with 38 additions and 30 deletions
@@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,16 +32,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject @Inject
class HomePresenter( class HomePresenter(
@@ -59,20 +54,13 @@ class HomePresenter(
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
val coroutineState = rememberCoroutineScope() val coroutineState = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState() val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by remember { client.totalUnreadCountFlow() }
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { // The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition.
syncService.heartBeat .collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() })
// 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 { val currentUserAndNeighbors by remember {
combine( combine(
client.userProfile, client.userProfile,
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.home.impl package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@@ -39,6 +41,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -124,7 +127,10 @@ class HomePresenterTest {
var unreadCount = 0L var unreadCount = 0L
val syncService = FakeSyncService() val syncService = FakeSyncService()
val presenter = createHomePresenter( val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.success(unreadCount) },
),
syncService = syncService, syncService = syncService,
) )
presenter.test { presenter.test {
@@ -140,7 +146,10 @@ class HomePresenterTest {
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService() val syncService = FakeSyncService()
val presenter = createHomePresenter( val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.failure(AN_EXCEPTION) },
),
syncService = syncService, syncService = syncService,
) )
presenter.test { presenter.test {
@@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -40,17 +41,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Inject @Inject
@@ -182,14 +178,8 @@ class PreferencesRootPresenter(
) )
} }
@OptIn(FlowPreview::class)
private fun unreadCountFlow(sessionId: SessionId): Flow<Int> { private fun unreadCountFlow(sessionId: SessionId): Flow<Int> {
val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0) val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0)
return client.syncService.heartBeat return client.totalUnreadCountFlow()
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.onStart { emit(Unit) }
.map { client.totalUnreadCount().getOrDefault(0).toInt() }
.distinctUntilChanged()
} }
} }
@@ -51,9 +51,15 @@ import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import java.util.Optional import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations. * Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations.
@@ -523,3 +529,18 @@ fun MatrixClient.roomAliasFromName(name: String): RoomAlias? {
?.takeIf { MatrixPatterns.isRoomAlias(it) } ?.takeIf { MatrixPatterns.isRoomAlias(it) }
?.let { tryOrNull { RoomAlias(it) } } ?.let { tryOrNull { RoomAlias(it) } }
} }
/**
* Returns a flow of the total number of unread notifications across all rooms.
* The first value is emitted immediately, then the value is refreshed on each sync heartbeat.
* In case of error, 0 is emitted.
*/
@OptIn(FlowPreview::class)
fun MatrixClient.totalUnreadCountFlow(): Flow<Int> {
return syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.onStart { emit(Unit) }
.map { totalUnreadCount().getOrDefault(0).toInt() }
.distinctUntilChanged()
}