Settings UI update.

- Reorder items
- Minor UI update
- Improve the previews of the Composable
- Merge manage account and manage devices
- Add missing tests
This commit is contained in:
Benoit Marty 2026-04-16 12:39:08 +02:00 committed by Benoit Marty
parent 38d2150b85
commit 74f147a9d7
18 changed files with 733 additions and 248 deletions

View file

@ -10,7 +10,7 @@ package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvents {
data object OnVersionInfoClick : PreferencesRootEvents
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
}

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
@ -99,9 +98,6 @@ class PreferencesRootPresenter(
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
val devicesManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
var canDeactivateAccount by remember {
mutableStateOf(false)
}
@ -110,9 +106,9 @@ class PreferencesRootPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val showBlockedUsersItem by produceState(initialValue = false) {
val nbOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.isNotEmpty() }
.onEach { value = it.size }
.launchIn(this)
}
@ -121,17 +117,17 @@ class PreferencesRootPresenter(
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
initAccountManagementUrl(accountManagementUrl)
}
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
fun handleEvent(event: PreferencesRootEvents) {
fun handleEvent(event: PreferencesRootEvent) {
when (event) {
is PreferencesRootEvents.OnVersionInfoClick -> {
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch {
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
}
@ -146,13 +142,12 @@ class PreferencesRootPresenter(
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
@ -162,9 +157,7 @@ class PreferencesRootPresenter(
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
devicesManagementUrl: MutableState<String?>,
) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull()
devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList).getOrNull()
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null).getOrNull()
}
}

View file

@ -23,15 +23,16 @@ data class PreferencesRootState(
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
val showBlockedUsersItem: Boolean,
val nbOfBlockedUsers: Int,
val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvents) -> Unit,
)
val eventSink: (PreferencesRootEvent) -> Unit,
) {
val showBlockedUsersItem = nbOfBlockedUsers > 0
}

View file

@ -8,36 +8,103 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRootState> {
override val values: Sequence<PreferencesRootState>
get() = sequenceOf(
// Nominal state, that a regular user will see if multi account is enabled
aPreferencesRootState(
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
showSecureBackup = true,
accountManagementUrl = "aUrl",
canReportBug = true,
showLinkNewDevice = true,
showAnalyticsSettings = true,
canDeactivateAccount = false,
nbOfBlockedUsers = 3,
showLabsItem = true,
),
aPreferencesRootState(
myUser = aMatrixUser(displayName = null),
isMultiAccountEnabled = true,
showSecureBackup = true,
canDeactivateAccount = true,
),
aPreferencesRootState(
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl",
showSecureBackup = true,
showSecureBackupBadge = true,
),
aPreferencesRootState(
deviceId = DeviceId("ILAKNDNASDLK"),
showLabsItem = true,
canReportBug = true,
nbOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
),
aPreferencesRootState(
showLinkNewDevice = true,
showAnalyticsSettings = true,
showDeveloperSettings = true,
canDeactivateAccount = true,
),
// Minimal state
aPreferencesRootState(),
)
}
fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)",
deviceId: DeviceId? = null,
isMultiAccountEnabled: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(),
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
accountManagementUrl: String? = null,
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
canDeactivateAccount: Boolean = false,
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
version = version,
deviceId = deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
otherSessions = otherSessions.toImmutableList(),
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
showLabsItem = true,
canDeactivateAccount = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = accountManagementUrl,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)

View file

@ -9,7 +9,6 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
@ -28,23 +27,20 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
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.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
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.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -82,22 +78,17 @@ fun PreferencesRootView(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
user = state.myUser,
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
} else {
HorizontalDivider()
}
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// User status will be added here
// 'Account' section
ManageAccountSection(
state = state,
@ -105,6 +96,13 @@ fun PreferencesRootView(
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
@ -118,12 +116,12 @@ fun PreferencesRootView(
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
deviceId = state.deviceId,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvents.OnVersionInfoClick) }
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
@ -142,13 +140,15 @@ private fun ColumnScope.MultiAccountSection(
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier.clickable {
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId))
},
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
)
HorizontalDivider()
}
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
@ -198,6 +198,14 @@ private fun ColumnScope.ManageAccountSection(
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showLinkNewDevice) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
@ -205,33 +213,15 @@ private fun ColumnScope.ManageAccountSection(
onClick = onLinkNewDeviceClick,
)
}
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
state.devicesManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showBlockedUsersItem) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
)
}
if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) {
if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) {
HorizontalDivider()
}
}
@ -248,6 +238,18 @@ private fun ColumnScope.GeneralSection(
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
@ -267,30 +269,17 @@ private fun ColumnScope.GeneralSection(
onClick = onOpenAnalytics,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
HorizontalDivider()
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive,
onClick = onSignOutClick,
)
if (state.canDeactivateAccount) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
style = ListItemStyle.Destructive,
onClick = onDeactivateClick,
)
@ -319,9 +308,8 @@ private fun ColumnScope.Footer(
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp),
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp),
textAlign = TextAlign.Center,
text = text,
style = ElementTheme.typography.fontBodySmRegular,
@ -340,19 +328,23 @@ private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewLight { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewLightPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
private fun ContentToPreview(state: PreferencesRootState) {
PreferencesRootView(
state = aPreferencesRootState(myUser = matrixUser),
state = state,
onBackClick = {},
onAddAccountClick = {},
onOpenAnalytics = {},
@ -372,16 +364,3 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onDeactivateClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun MultiAccountSectionPreview() = ElementPreview {
Column {
MultiAccountSection(
state = aPreferencesRootState(
otherSessions = aMatrixUserList(),
),
onAddAccountClick = {},
)
}
}

View file

@ -15,21 +15,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
@Composable
fun UserPreferences(
user: MatrixUser?,
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
) {
MatrixUserHeader(
modifier = modifier,
matrixUser = user
matrixUser = matrixUser,
)
}
@PreviewsDayNight
@Composable
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview {
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
UserPreferences(matrixUser)
}

View file

@ -28,6 +28,8 @@ 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.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_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
@ -40,7 +42,9 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -73,6 +77,7 @@ class PreferencesRootPresenterTest {
assertThat(initialState.version).isEqualTo("A Version")
assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem()
assertThat(loadedState.myUser).isEqualTo(
MatrixUser(
@ -81,27 +86,21 @@ class PreferencesRootPresenterTest {
avatarUrl = AN_AVATAR_URL
)
)
assertThat(initialState.version).isEqualTo("A Version")
assertThat(loadedState.showSecureBackup).isFalse()
assertThat(loadedState.showSecureBackupBadge).isFalse()
assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse()
assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0)
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull()
skipItems(1)
val finalState = awaitItem()
accountManagementUrlResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(AccountManagementAction.Profile)),
listOf(value(AccountManagementAction.DevicesList)),
)
assertThat(finalState.accountManagementUrl).isEqualTo("Profile url")
assertThat(finalState.devicesManagementUrl).isEqualTo("DevicesList url")
accountManagementUrlResult.assertions().isCalledOnce()
.with(value(null))
assertThat(finalState.accountManagementUrl).isEqualTo("null url")
}
}
@ -121,6 +120,22 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - number of blocked users`() = runTest {
val matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success("") },
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)),
)
createPresenter(
matrixClient = matrixClient,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.nbOfBlockedUsers).isEqualTo(2)
}
}
@Test
fun `present - secure backup badge`() = runTest {
val matrixClient = FakeMatrixClient(
@ -181,12 +196,36 @@ class PreferencesRootPresenterTest {
val loadedState = awaitFirstItem()
repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) {
assertThat(loadedState.showDeveloperSettings).isFalse()
loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick)
loadedState.eventSink(PreferencesRootEvent.OnVersionInfoClick)
}
assertThat(awaitItem().showDeveloperSettings).isTrue()
}
}
@Test
fun `present - switch session invoke method on the session store`() = runTest {
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
setLatestSessionResult = setLatestSessionResult,
)
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
sessionStore = sessionStore,
).test {
val loadedState = awaitFirstItem()
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPresenter(

View file

@ -0,0 +1,483 @@
/*
* 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.features.preferences.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PreferencesRootViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes back callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
rule.pressBack()
}
}
@Test
fun `click on User profile invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
val user = aMatrixUser()
ensureCalledOnceWithParam(user) { callback ->
rule.setView(
aPreferencesRootState(
myUser = user,
eventSink = eventsRecorder,
),
onOpenUserProfile = callback,
)
rule.onNodeWithText("Alice").performClick()
}
}
@Test
fun `clicking on other session sends a SwitchToSession`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
rule.onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
}
@Test
fun `click on Add account invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
eventSink = eventsRecorder,
),
onAddAccountClick = callback,
)
rule.clickOn(CommonStrings.common_add_another_account)
}
}
@Test
fun `when multi account is not enabled, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
isMultiAccountEnabled = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_add_another_account)).assertDoesNotExist()
}
@Test
fun `click on Encryption invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showSecureBackup = true,
eventSink = eventsRecorder,
),
onSecureBackupClick = callback,
)
rule.clickOn(CommonStrings.common_encryption)
}
}
@Test
fun `when showSecureBackup is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showSecureBackup = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_encryption)).assertDoesNotExist()
}
@Test
fun `click on Manage account invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam("aUrl") { callback ->
rule.setView(
aPreferencesRootState(
accountManagementUrl = "aUrl",
eventSink = eventsRecorder,
),
onManageAccountClick = callback,
)
rule.clickOn(CommonStrings.action_manage_account_and_devices)
}
}
@Test
fun `when accountManagementUrl is null, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
accountManagementUrl = null,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist()
}
@Test
fun `click on Link new devices invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showLinkNewDevice = true,
eventSink = eventsRecorder,
),
onLinkNewDeviceClick = callback,
)
rule.clickOn(CommonStrings.common_link_new_device)
}
}
@Test
fun `when showLinkNewDevice is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showLinkNewDevice = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_link_new_device)).assertDoesNotExist()
}
@Test
fun `click on Analytics invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showAnalyticsSettings = true,
eventSink = eventsRecorder,
),
onOpenAnalytics = callback,
)
rule.clickOn(CommonStrings.common_analytics)
}
}
@Test
fun `when showAnalyticsSettings is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showAnalyticsSettings = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_analytics)).assertDoesNotExist()
}
@Test
fun `click on Report a problem invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
canReportBug = true,
eventSink = eventsRecorder,
),
onOpenRageShake = callback,
)
rule.clickOn(CommonStrings.common_report_a_problem)
}
}
@Test
fun `when canReportBug is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
canReportBug = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist()
}
@Test
fun `click on Screen lock invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenLockScreenSettings = callback,
)
rule.clickOn(CommonStrings.common_screen_lock)
}
}
@Test
fun `click on About invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenAbout = callback,
)
rule.clickOn(CommonStrings.common_about)
}
}
@Test
fun `click on Developer settings invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showDeveloperSettings = true,
eventSink = eventsRecorder,
),
onOpenDeveloperSettings = callback,
)
rule.clickOn(CommonStrings.common_developer_options)
}
}
@Test
fun `when showDeveloperSettings is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showDeveloperSettings = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_developer_options)).assertDoesNotExist()
}
@Test
fun `click on Advanced settings invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenAdvancedSettings = callback,
)
rule.clickOn(CommonStrings.common_advanced_settings)
}
}
@Test
fun `click on Labs invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
showLabsItem = true,
eventSink = eventsRecorder,
),
onOpenLabs = callback,
)
rule.clickOn(R.string.screen_labs_title)
}
}
@Test
fun `when showLabsItem is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
showLabsItem = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(R.string.screen_labs_title)).assertDoesNotExist()
}
@Test
fun `click on Notification invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onOpenNotificationSettings = callback,
)
rule.clickOn(R.string.screen_notification_settings_title)
}
}
@Test
fun `click on Blocked users invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
nbOfBlockedUsers = 1,
eventSink = eventsRecorder,
),
onOpenBlockedUsers = callback,
)
rule.clickOn(CommonStrings.common_blocked_users)
}
}
@Test
fun `when nbOfBlockedUsers is 0, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
nbOfBlockedUsers = 0,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
}
@Test
fun `click on Remove this device invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
eventSink = eventsRecorder,
),
onSignOutClick = callback,
)
rule.clickOn(CommonStrings.action_signout)
}
}
@Test
fun `click on Deactivate invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setView(
aPreferencesRootState(
canDeactivateAccount = true,
eventSink = eventsRecorder,
),
onDeactivateClick = callback,
)
rule.clickOn(CommonStrings.action_deactivate_account)
}
}
@Test
fun `when canDeactivateAccount is false, item is not shown`() {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView(
aPreferencesRootState(
canDeactivateAccount = false,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_deactivate_account)).assertDoesNotExist()
}
@Test
fun `clicking on version sends a PreferencesRootEvents`() {
val version = "VERSION"
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView(
aPreferencesRootState(
version = version,
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(version).performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
onSecureBackupClick: () -> Unit = EnsureNeverCalled(),
onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(),
onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(),
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
onOpenRageShake: () -> Unit = EnsureNeverCalled(),
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
onOpenAbout: () -> Unit = EnsureNeverCalled(),
onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(),
onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(),
onOpenLabs: () -> Unit = EnsureNeverCalled(),
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
onSignOutClick: () -> Unit = EnsureNeverCalled(),
onDeactivateClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PreferencesRootView(
state = state,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSecureBackupClick = onSecureBackupClick,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenLabs = onOpenLabs,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenUserProfile = onOpenUserProfile,
onOpenBlockedUsers = onOpenBlockedUsers,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
}