Merge pull request #7328 from element-hq/feature/fga/user_status_last_pieces

User status last pieces
This commit is contained in:
ganfra
2026-08-11 11:11:12 +02:00
committed by GitHub
155 changed files with 979 additions and 357 deletions
+1
View File
@@ -60,6 +60,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test) testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
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.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter = private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this) }.launchIn(this)
} }
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState() val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) { LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) { if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
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
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
) )
} }
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
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.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
}, },
// We want a 16dp left padding for the navigationIcon : // We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding // 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton) // 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets // 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp), windowInsets = WindowInsets(left = 4.dp),
) )
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton( val interactionSource = remember { MutableInteractionSource() }
modifier = modifier.then(testTag), Box(
onClick = onClick, modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) { ) {
Box {
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) { contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) { if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action) stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null null
}, },
) )
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) { if (showAvatarIndicator) {
RedIndicatorAtom( RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) )
} }
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
} }
} }
} }
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
} }
fun onUserDataClick() { fun onUserDataClick() {
val sender = MatrixUser( val sender = event.senderProfile.toMatrixUser(event.senderId)
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
onUserDataClick(sender) onUserDataClick(sender)
} }
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
val showLinkNewDevice by remember { val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null val isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val otherSessions by remember { val otherSessions by remember {
sessionStore.sessionsFlow().map { list -> sessionStore.sessionsFlow().map { list ->
@@ -9,9 +9,12 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -25,7 +28,10 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -71,71 +77,87 @@ fun PreferencesRootView(
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules Box(modifier = modifier) {
PreferencePage( // Include pref from other modules
modifier = modifier, PreferencePage(
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings), title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
) { ) {
UserPreferences( UserPreferences(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onOpenUserProfile(state.myUser) onOpenUserProfile(state.myUser)
}, },
matrixUser = state.myUser, matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
) )
} if (state.isMultiAccountEnabled) {
if (state.userStatusState != null) { MultiAccountSection(
UserStatusSection( state = state,
userStatusState = state.userStatusState, onAddAccountClick = onAddAccountClick,
emojiPickerRenderer = emojiPickerRenderer, )
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
} }
) if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
)
}
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
} }
} }
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
}
@Composable @Composable
private fun ColumnScope.UserStatusSection( private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState, userStatusState: UserStatusState,
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState() val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) { fun handleEvent(event: UserStatusEvent) {
when (event) { when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> { is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
} }
UserStatusEvent.ClearStatus -> { UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
} }
UserStatusEvent.OpenCustomInput -> { UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus, displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus, rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState, pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?, val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?, val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState, val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit, val eventSink: (UserStatusEvent) -> Unit,
) )
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null, displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null, rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {}, eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState( ) = UserStatusState(
displayedStatus = displayedStatus, displayedStatus = displayedStatus,
rawStatus = rawStatus, rawStatus = rawStatus,
pickerState = pickerState, pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem() val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue() assertThat(client.setUserStatusCalled).isTrue()
} }
} }
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull() assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue() assertThat(client.clearUserStatusCalled).isTrue()
} }
} }
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private val DefaultShowDelay = 300.milliseconds
private val DefaultMinDisplayDuration = 500.milliseconds
private val DefaultFailureDuration = 500.milliseconds
/**
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
*
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
* prevents the indicator from flashing for operations that complete almost instantly.
*
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
* never debounced) and auto-dismisses after [failureDuration].
*
* @param asyncAction the action whose progress drives the indicator.
* @param loading the composable to display while loading.
* @param failure the composable to display on failure.
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
* @param state the [AsyncIndicatorState] backing the host.
* @param showDelay how long the loading state must persist before the indicator is shown.
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
*/
@Composable
fun AsyncActionIndicator(
asyncAction: AsyncAction<*>,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
showDelay: Duration = DefaultShowDelay,
minDisplayDuration: Duration = DefaultMinDisplayDuration,
failureDuration: Duration = DefaultFailureDuration,
) {
AsyncIndicatorHost(modifier = modifier, state = state)
AsyncActionIndicatorEffect(
asyncAction = asyncAction,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplayDuration,
failureDuration = failureDuration,
loading = loading,
failure = failure,
)
}
@Composable
internal fun AsyncActionIndicatorEffect(
asyncAction: AsyncAction<*>,
state: AsyncIndicatorState,
showDelay: Duration,
minDisplayDuration: Duration,
failureDuration: Duration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
timeSource: TimeSource = TimeSource.Monotonic,
) {
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
val currentLoading by rememberUpdatedState(loading)
val currentFailure by rememberUpdatedState(failure)
// Mark set when the loader actually becomes visible, null while it is hidden.
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
LaunchedEffect(asyncAction) {
when (asyncAction) {
is AsyncAction.Loading -> {
// If the operation completes before this delay elapses, the effect is cancelled
// and the loader is never shown.
delay(showDelay)
state.enqueue(composable = currentLoading)
loaderShownAt = timeSource.markNow()
}
is AsyncAction.Failure -> {
// Always surface failures immediately, even if the loader was never shown.
val error = asyncAction.error
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
loaderShownAt = null
}
is AsyncAction.Success -> {
// Hold the loader for its minimum duration before clearing it.
loaderShownAt?.let { shownAt ->
val remaining = minDisplayDuration - shownAt.elapsedNow()
if (remaining > Duration.ZERO) delay(remaining)
}
state.clear()
loaderShownAt = null
}
else -> Unit
}
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.component.async
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncActionIndicatorTest {
private val showDelay = 300.milliseconds
private val minDisplay = 500.milliseconds
@Test
fun `loader is not shown before the show delay elapses`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
// Once the show delay has fully elapsed, the loader is shown.
advanceTimeBy(300.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader is never shown when the operation completes before the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
action.value = AsyncAction.Success(Unit)
advanceTimeBy(1000.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Failure(Exception("failed"))
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader stays visible for the minimum duration once shown`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
val timeSource = TestTimeSource()
indicatorFlow(action, state, timeSource).test {
// Show the loader.
action.value = AsyncAction.Loading
advanceTimeBy(400.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// 200ms of display has already elapsed when the operation succeeds.
timeSource += 200.milliseconds
action.value = AsyncAction.Success(Unit)
runCurrent()
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// Past the minimum display duration -> cleared.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun indicatorFlow(
action: State<AsyncAction<*>>,
state: AsyncIndicatorState,
timeSource: TimeSource = TimeSource.Monotonic,
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
AsyncActionIndicatorEffect(
asyncAction = action.value,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplay,
failureDuration = minDisplay,
loading = {},
failure = {},
timeSource = timeSource,
)
state.currentAnimationState.targetState
}
}
@@ -99,6 +99,12 @@ interface MatrixClient {
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */ /** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit> suspend fun clearUserStatus(): Result<Unit>
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result<Boolean>
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
fun enableAutomaticCallStatus(enabled: Boolean)
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?> suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId, userId = userId,
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN }, displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN }, avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
displayedStatus = displayedStatus,
) )
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
@Immutable @Immutable
sealed interface ProfileDetails { sealed interface ProfileDetails {
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
else -> null else -> null
} }
} }
fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser {
return when (this) {
is ProfileDetails.Ready -> MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
)
else -> MatrixUser(
userId = userId,
displayName = null,
avatarUrl = null,
displayedStatus = null,
)
}
}
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
@Immutable @Immutable
sealed interface DisplayedStatus : Parcelable { sealed interface DisplayedStatus : Parcelable {
/** Useful for adding extension methods. */
companion object
/** Status set manually by the user via m.status. */ /** Status set manually by the user via m.status. */
@Parcelize @Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus data class UserSet(val status: UserStatus) : DisplayedStatus
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
@Parcelize @Parcelize
data class InCall( data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */ /** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long, val callJoinedTs: Long?,
) : DisplayedStatus ) : DisplayedStatus
} }
@@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.into
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UserProfile
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@@ -271,8 +273,6 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow( private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser( MatrixUser(
userId = sessionId, userId = sessionId,
@@ -283,6 +283,14 @@ class RustMatrixClient(
override val userProfile: StateFlow<MatrixUser> = _userProfile override val userProfile: StateFlow<MatrixUser> = _userProfile
private var ownProfileTaskHandle: TaskHandle? = null
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
_userProfile.tryEmit(profile.map())
}
}
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> { override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
// Fetch the initial value manually, the SDK won't return it automatically // Fetch the initial value manually, the SDK won't return it automatically
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList()) channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
@@ -303,8 +311,21 @@ class RustMatrixClient(
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
// Start notification settings // Start notification settings
notificationSettingsService.start() notificationSettingsService.start()
// Setup user profile logic
setupUserProfile()
}
// Update the user profile in the session store if needed // Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
} else {
// Seed from the session store so cold-start UI has something to render before the network fetch.
sessionStore.getSession(sessionId.value)?.let { sessionData -> sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit( _userProfile.emit(
MatrixUser( MatrixUser(
@@ -314,12 +335,8 @@ class RustMatrixClient(
) )
) )
} }
// Force a refresh of the profile
getUserProfile() getUserProfile()
} }
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
} }
override fun userIdServerName(): String { override fun userIdServerName(): String {
@@ -458,13 +475,8 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId) override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser -> .onSuccess { matrixUser ->
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage // Also update our session storage
_userProfile.emit(matrixUser)
sessionStore.updateUserProfile( sessionStore.updateUserProfile(
sessionId = sessionId.value, sessionId = sessionId.value,
displayName = matrixUser.displayName, displayName = matrixUser.displayName,
@@ -495,25 +507,31 @@ class RustMatrixClient(
} }
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) { override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = status runCatchingExceptions {
_userProfile.emit( innerClient.setUserStatus(status.into())
_userProfile.value.copy( }.onSuccess {
rawStatus = status, // The subscription pushes the update on supported servers; only refresh manually as a fallback.
displayedStatus = DisplayedStatus.UserSet(status), if (ownProfileTaskHandle == null) getUserProfile()
) }
)
Result.success(Unit)
} }
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) { override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null runCatchingExceptions {
_userProfile.emit( innerClient.clearUserStatus()
_userProfile.value.copy( }.onSuccess {
rawStatus = null, // The subscription pushes the update on supported servers; only refresh manually as a fallback.
displayedStatus = null, if (ownProfileTaskHandle == null) getUserProfile()
) }
) }
Result.success(Unit)
override suspend fun isUserStatusSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isUserStatusSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
} }
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) { override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
@@ -621,6 +639,7 @@ class RustMatrixClient(
sessionCoroutineScope.cancel() sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle?.cancelAndDestroy()
ownProfileTaskHandle?.cancelAndDestroy()
sessionVerificationService.destroy() sessionVerificationService.destroy()
sessionDelegate.clearCurrentClient() sessionDelegate.clearCurrentClient()
@@ -155,6 +155,7 @@ class RustMatrixClientFactory(
val syncService = client.syncService() val syncService = client.syncService()
.withSharePos(true) .withSharePos(true)
.withOfflineMode() .withOfflineMode()
.withProfilesExtension()
.finish() .finish()
return RustMatrixClient( return RustMatrixClient(
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ
import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.user.from
import io.element.android.libraries.matrix.impl.user.into
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
fun RoomHero.map(): MatrixUser = MatrixUser( fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId), userId = UserId(userId),
displayName = displayName, displayName = displayName,
avatarUrl = avatarUrl avatarUrl = avatarUrl,
displayedStatus = DisplayedStatus.from(status, call),
rawStatus = status?.into(),
) )
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels { fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
@@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.room.powerlevels.into import io.element.android.libraries.matrix.impl.room.powerlevels.into
import io.element.android.libraries.matrix.impl.user.from
import uniffi.matrix_sdk.RoomMemberRole import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@@ -30,8 +32,7 @@ object RoomMemberMapper {
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel), role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason, membershipChangeReason = roomMember.membershipChangeReason,
isServiceMember = roomMember.isServiceMember, isServiceMember = roomMember.isServiceMember,
// TODO map this value when available displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
displayedStatus = null,
) )
} }
@@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.core.RustSendHandle import io.element.android.libraries.matrix.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.user.from
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
displayName = displayName, displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
// TODO map this value when available displayedStatus = DisplayedStatus.from(status, call),
displayedStatus = null,
) )
} }
} }
@@ -6,9 +6,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl.mapper package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile import org.matrix.rustcomponents.sdk.UserProfile
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
userId = UserId(userId), userId = UserId(userId),
displayName = displayName, displayName = displayName,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
rawStatus = status?.into(),
displayedStatus = DisplayedStatus.from(status, call)
) )
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl.usersearch package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.impl.mapper.map
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults import org.matrix.rustcomponents.sdk.SearchUsersResults
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import org.matrix.rustcomponents.sdk.UserCall
import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus
fun UserStatus.into(): RustUserStatus {
return RustUserStatus(emoji = emoji, text = text)
}
fun RustUserStatus.into(): UserStatus {
return UserStatus(emoji = emoji, text = text)
}
fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? {
return when {
userStatus != null -> DisplayedStatus.UserSet(userStatus.into())
userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong())
else -> null
}
}
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.user.map
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
@@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
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.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
@@ -72,7 +72,8 @@ class RustMatrixClientTest {
@Test @Test
fun `retrieving the UserProfile updates the database`() = runTest { fun `retrieving the UserProfile updates the database`() = runTest {
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> } val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> profilePersisted.complete(Unit) }
val sessionStore = InMemorySessionStore( val sessionStore = InMemorySessionStore(
initialList = listOf( initialList = listOf(
aSessionData( aSessionData(
@@ -97,7 +98,7 @@ class RustMatrixClientTest {
), ),
sessionStore = sessionStore, sessionStore = sessionStore,
) )
advanceUntilIdle() profilePersisted.await()
updateUserProfileResult.assertions().isCalledOnce() updateUserProfileResult.assertions().isCalledOnce()
.with( .with(
value(A_USER_ID.value), value(A_USER_ID.value),
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind import org.matrix.rustcomponents.sdk.PusherKind
import org.matrix.rustcomponents.sdk.RoomDirectorySearch import org.matrix.rustcomponents.sdk.RoomDirectorySearch
@@ -55,6 +56,8 @@ class FakeFfiClient(
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val isUserStatusSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val closeResult: () -> Unit = {}, private val closeResult: () -> Unit = {},
) : Client(NoHandle) { ) : Client(NoHandle) {
override fun userId(): String = userId override fun userId(): String = userId
@@ -92,6 +95,13 @@ class FakeFfiClient(
return FakeFfiTaskHandle() return FakeFfiTaskHandle()
} }
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
}
override suspend fun getProfile(userId: String): UserProfile { override suspend fun getProfile(userId: String): UserProfile {
return getProfileResult(userId) return getProfileResult(userId)
} }
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) { class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
override fun withOfflineMode(): SyncServiceBuilder = this override fun withOfflineMode(): SyncServiceBuilder = this
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
override fun withProfilesExtension(): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeFfiSyncService() override suspend fun finish(): SyncService = FakeFfiSyncService()
} }
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Test import org.junit.Test
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.junit.Test import org.junit.Test
@@ -130,6 +130,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() }, private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null, override val contentScanner: ContentScanner? = null,
) : MatrixClient { ) : MatrixClient {
var setDisplayNameCalled: Boolean = false var setDisplayNameCalled: Boolean = false
@@ -144,6 +145,7 @@ class FakeMatrixClient(
private set private set
var setUserStatusResult: Result<Unit> = Result.success(Unit) var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit) var clearUserStatusResult: Result<Unit> = Result.success(Unit)
var isUserStatusSupportedResult: Result<Boolean> = Result.success(true)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -288,6 +290,10 @@ class FakeMatrixClient(
return clearUserStatusResult return clearUserStatusResult
} }
override suspend fun isUserStatusSupported(): Result<Boolean> = isUserStatusSupportedResult
override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled)
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId) override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> { override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
@@ -98,6 +98,7 @@ class KonsistPreviewTest {
"HomeTopBarSpaceFiltersSelectedPreview", "HomeTopBarSpaceFiltersSelectedPreview",
"HomeTopBarSpacesPreview", "HomeTopBarSpacesPreview",
"HomeTopBarWithIndicatorPreview", "HomeTopBarWithIndicatorPreview",
"HomeTopBarWithStatusPreview",
"IconsOtherPreview", "IconsOtherPreview",
"MarkdownTextComposerEditPreview", "MarkdownTextComposerEditPreview",
"MatrixBadgeAtomInfoPreview", "MatrixBadgeAtomInfoPreview",

Some files were not shown because too many files have changed in this diff Show More