Merge branch 'develop' into feature/bma/swipeAvatarAnnouncement

Account switching is now handled by RootFlowNode, through the onSwitchToAccount callback, instead
of HomeEvent.SwitchToAccount. To keep dismissing the swipe avatar announcement when the user
switches account by swiping their avatar, HomeView now also sends the new
HomeEvent.DismissSwipeAvatarAnnouncement.

Also opt in to ExperimentalCoroutinesApi in the HomePresenter tests using advanceTimeBy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 17:39:24 +02:00
138 changed files with 1205 additions and 597 deletions
@@ -8,10 +8,8 @@
package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
data object DismissSwipeAvatarAnnouncement : HomeEvent
}
@@ -222,6 +222,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToAccount = callback::navigateToSession,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -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<HomeState> {
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,
@@ -110,11 +126,8 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
is HomeEvent.SwitchToAccount -> coroutineState.launch {
// The user has switched account by swiping their avatar, so they know the gesture.
// Do it before switching, since it can stop this coroutine.
HomeEvent.DismissSwipeAvatarAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
sessionStore.setLatestSession(event.sessionId.value)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
@@ -133,6 +146,7 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
totalUnreadCount = totalUnreadCount,
eventSink = ::handleEvent,
)
}
@@ -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
@@ -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,
)
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -78,6 +79,7 @@ fun HomeView(
onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
@@ -130,6 +132,7 @@ fun HomeView(
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
onSwitchToAccount = onSwitchToAccount,
)
if (state.globalSearchState.isEnabled) {
@@ -167,6 +170,7 @@ private fun HomeScaffold(
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomClick(room: RoomListRoomSummary) {
@@ -213,8 +217,10 @@ private fun HomeScaffold(
},
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
state.eventSink(HomeEvent.SwitchToAccount(it))
onAccountSwitch = { sessionId ->
// The user has switched account by swiping their avatar, so they know the gesture.
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
onSwitchToAccount(sessionId)
},
scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters,
@@ -259,6 +265,7 @@ private fun HomeScaffold(
}
}
},
totalUnreadCount = state.totalUnreadCount,
)
},
floatingActionButtonPosition = FabPosition.Center,
@@ -341,6 +348,7 @@ private fun HomeFloatingActionButton(
@Composable
private fun HomeBottomBar(
totalUnreadCount: Int,
currentHomeNavigationBarItem: HomeNavigationBarItem,
onItemClick: (HomeNavigationBarItem) -> Unit,
modifier: Modifier = Modifier,
@@ -361,6 +369,7 @@ private fun HomeBottomBar(
tooltipLabel = stringResource(item.labelRes),
isSelected = isSelected,
onClick = { onItemClick(item) },
counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 }
)
}
}
@@ -376,6 +385,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -397,6 +407,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -28,8 +28,6 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -43,10 +41,12 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class HomePresenterTest {
@get:Rule
@@ -121,6 +121,40 @@ class HomePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@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)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@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(
@@ -252,31 +286,29 @@ class HomePresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - switching account dismisses the swipe avatar announcement`() = runTest {
fun `present - dismissing the swipe avatar announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val presenter = createHomePresenter(
client = FakeMatrixClient(sessionId = A_SESSION_ID),
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
updateUserProfileResult = { _, _, _, _ -> },
setLatestSessionResult = setLatestSessionResult,
),
announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
)
presenter.test {
val state = expectMostRecentItem()
state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2))
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
runCurrent()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@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)
}
}
}
@@ -331,6 +331,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick,
onSwitchToAccount = EnsureNeverCalledWithParam(),
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,