Merge pull request #7788 from element-hq/feature/bma/removeAccount

Improve the sign out screen
This commit is contained in:
Benoit Marty
2026-09-30 11:53:19 +02:00
committed by GitHub
64 changed files with 447 additions and 165 deletions
+12 -4
View File
@@ -2,17 +2,25 @@ appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: - tapOn:
id: "home_screen-settings" id: "home_screen-settings"
- tapOn:
id: "settings-user_profile"
- assertVisible: "Your account"
- tapOn: "Remove this device" - tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
- tapOn: "Remove this device" - tapOn: "Remove this device"
# Ensure pressing back cancels
- back
- tapOn: "Remove this device"
# Ensure cancel cancels # Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Remove this device"
- tapOn:
text: "Remove this device"
index: 1
# Cancel the dialog cancels the logout
- tapOn: - tapOn:
id: "dialog-negative" id: "dialog-negative"
- tapOn: "Remove this device" - tapOn:
text: "Remove this device"
index: 1
- tapOn: - tapOn:
id: "dialog-positive" id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml - runFlow: ../assertions/assertInitDisplayed.yaml
-2
View File
@@ -53,7 +53,6 @@ dependencies {
implementation(projects.features.announcement.api) implementation(projects.features.announcement.api)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@@ -67,7 +66,6 @@ dependencies {
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.announcement.test) testImplementation(projects.features.announcement.test)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
@@ -40,7 +40,6 @@ import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.leaveroom.api.LeaveRoomRenderer import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
@@ -83,7 +82,6 @@ class HomeFlowNode(
private val inviteFriendsUseCase: InviteFriendsUseCase, private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView, private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint, private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
@@ -248,7 +246,6 @@ class HomeFlowNode(
) )
} }
) )
directLogoutView.Render(state.directLogoutState)
} }
} }
@@ -21,7 +21,6 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -42,7 +41,6 @@ class HomePresenter(
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>, private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>, private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
) : Presenter<HomeState> { ) : Presenter<HomeState> {
@@ -75,7 +73,6 @@ class HomePresenter(
} }
// Avatar indicator // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present()
fun handleEvent(event: HomeEvent) { fun handleEvent(event: HomeEvent) {
when (event) { when (event) {
@@ -98,7 +95,6 @@ class HomePresenter(
homeSpacesState = homeSpacesState, homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
directLogoutState = directLogoutState,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -11,7 +11,6 @@ package io.element.android.features.home.impl
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -29,7 +28,6 @@ data class HomeState(
val homeSpacesState: HomeSpacesState, val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean, val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
val eventSink: (HomeEvent) -> Unit, val eventSink: (HomeEvent) -> Unit,
) { ) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -16,8 +16,6 @@ import io.element.android.features.home.impl.roomlist.aRoomsContentState
import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList
import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState
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.designsystem.utils.snackbar.SnackbarMessage
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.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -63,7 +61,6 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(), roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true, canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvent) -> Unit = {} eventSink: (HomeEvent) -> Unit = {}
) = HomeState( ) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -71,7 +68,6 @@ internal fun aHomeState(
hasNetworkConnection = hasNetworkConnection, hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
directLogoutState = directLogoutState,
currentHomeNavigationBarItem = currentHomeNavigationBarItem, currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState, roomListState = roomListState,
homeSpacesState = homeSpacesState, homeSpacesState = homeSpacesState,
@@ -36,7 +36,6 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
inviteFriendsUseCase = { lambdaError() }, inviteFriendsUseCase = { lambdaError() },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() },
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
@@ -12,7 +12,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -162,7 +161,6 @@ internal fun createHomePresenter(
indicatorService = indicatorService, indicatorService = indicatorService,
roomListPresenter = { aRoomListState() }, roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter, homeSpacesPresenter = homeSpacesPresenter,
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore, sessionStore = sessionStore,
) )
@@ -11,7 +11,6 @@ package io.element.android.features.logout.api.direct
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
data class DirectLogoutState( data class DirectLogoutState(
val canDoDirectSignOut: Boolean,
val logoutAction: AsyncAction<Unit>, val logoutAction: AsyncAction<Unit>,
val eventSink: (DirectLogoutEvent) -> Unit, val eventSink: (DirectLogoutEvent) -> Unit,
) )
@@ -23,11 +23,9 @@ open class DirectLogoutStatePreviewParam : PreviewParameterProvider<DirectLogout
} }
fun aDirectLogoutState( fun aDirectLogoutState(
canDoDirectSignOut: Boolean = true,
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (DirectLogoutEvent) -> Unit = {}, eventSink: (DirectLogoutEvent) -> Unit = {},
) = DirectLogoutState( ) = DirectLogoutState(
canDoDirectSignOut = canDoDirectSignOut,
logoutAction = logoutAction, logoutAction = logoutAction,
eventSink = eventSink, eventSink = eventSink,
) )
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
@@ -41,6 +41,7 @@ class LogoutPresenter(
@Composable @Composable
override fun present(): LogoutState { override fun present(): LogoutState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val currentUser by matrixClient.userProfile.collectAsState()
val logoutAction: MutableState<AsyncAction<Unit>> = remember { val logoutAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
@@ -90,6 +91,7 @@ class LogoutPresenter(
} }
return LogoutState( return LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
@@ -12,8 +12,10 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
data class LogoutState( data class LogoutState(
val currentUser: MatrixUser,
val isLastDevice: Boolean, val isLastDevice: Boolean,
val backupState: BackupState, val backupState: BackupState,
val doesBackupExistOnServer: Boolean, val doesBackupExistOnServer: Boolean,
@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> { open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> {
override val values: Sequence<LogoutState> override val values: Sequence<LogoutState>
@@ -39,10 +41,12 @@ open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> {
backupUploadState = BackupUploadState.Waiting, backupUploadState = BackupUploadState.Waiting,
waitingForALongTime = true, waitingForALongTime = true,
), ),
aLogoutState(currentUser = aMatrixUser(id = "@alice:server.org", displayName = null)),
) )
} }
fun aLogoutState( fun aLogoutState(
currentUser: MatrixUser = aMatrixUser(),
isLastDevice: Boolean = false, isLastDevice: Boolean = false,
backupState: BackupState = BackupState.ENABLED, backupState: BackupState = BackupState.ENABLED,
doesBackupExistOnServer: Boolean = true, doesBackupExistOnServer: Boolean = true,
@@ -52,6 +56,7 @@ fun aLogoutState(
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LogoutEvent) -> Unit = {}, eventSink: (LogoutEvent) -> Unit = {},
) = LogoutState( ) = LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice, isLastDevice = isLastDevice,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer, doesBackupExistOnServer = doesBackupExistOnServer,
@@ -11,7 +11,9 @@ package io.element.android.features.logout.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -32,15 +34,22 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
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.TextButton
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
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
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=442-34581
*/
@Composable @Composable
fun LogoutView( fun LogoutView(
state: LogoutState, state: LogoutState,
@@ -54,15 +63,17 @@ fun LogoutView(
onBackClick = onBackClick, onBackClick = onBackClick,
title = title(state), title = title(state),
subTitle = subtitle(state), subTitle = subtitle(state),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), iconStyle = BigIcon.Style.Default(CompoundIcons.Mobile()),
modifier = modifier, modifier = modifier,
isScrollable = true,
buttons = { buttons = {
Buttons( Buttons(
state = state, state = state,
onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, onChangeRecoveryKeyClick = onChangeRecoveryKeyClick,
onLogoutClick = { onLogoutClick = {
eventSink(LogoutEvent.Logout(ignoreSdkError = false)) eventSink(LogoutEvent.Logout(ignoreSdkError = false))
} },
onCancelClick = onBackClick,
) )
}, },
) { ) {
@@ -71,6 +82,8 @@ fun LogoutView(
LogoutActionDialog( LogoutActionDialog(
state.logoutAction, state.logoutAction,
// Do not show the progress dialog, the progress is rendered in the button.
showProgressDialog = false,
onConfirmClick = { onConfirmClick = {
eventSink(LogoutEvent.Logout(ignoreSdkError = false)) eventSink(LogoutEvent.Logout(ignoreSdkError = false))
}, },
@@ -116,6 +129,7 @@ private fun ColumnScope.Buttons(
state: LogoutState, state: LogoutState,
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
onChangeRecoveryKeyClick: () -> Unit, onChangeRecoveryKeyClick: () -> Unit,
onCancelClick: () -> Unit,
) { ) {
val logoutAction = state.logoutAction val logoutAction = state.logoutAction
if (state.isLastDevice) { if (state.isLastDevice) {
@@ -139,6 +153,12 @@ private fun ColumnScope.Buttons(
.testTag(TestTags.signOut), .testTag(TestTags.signOut),
onClick = onLogoutClick, onClick = onLogoutClick,
) )
TextButton(
text = stringResource(id = CommonStrings.action_cancel),
enabled = logoutAction !is AsyncAction.Loading,
modifier = Modifier.fillMaxWidth(),
onClick = onCancelClick,
)
} }
@Composable @Composable
@@ -149,9 +169,16 @@ private fun Content(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 60.dp, start = 20.dp, end = 20.dp), .padding(top = 8.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
AccountInfoCard(
AccountInfoCardState(
matrixUser = state.currentUser,
mode = AccountInfoCardMode.Simple,
)
)
Spacer(modifier = Modifier.height(16.dp))
when (state.backupUploadState) { when (state.backupUploadState) {
is BackupUploadState.Uploading -> { is BackupUploadState.Uploading -> {
LinearProgressIndicator( LinearProgressIndicator(
@@ -27,6 +27,7 @@ class DefaultDirectLogoutView : DirectLogoutView {
val eventSink = state.eventSink val eventSink = state.eventSink
LogoutActionDialog( LogoutActionDialog(
state.logoutAction, state.logoutAction,
showProgressDialog = true,
onConfirmClick = { onConfirmClick = {
eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
}, },
@@ -10,28 +10,22 @@ package io.element.android.features.logout.impl.direct
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.impl.tools.isBackingUp
import io.element.android.libraries.architecture.AsyncAction 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.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
class DirectLogoutPresenter( class DirectLogoutPresenter(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService,
) : Presenter<DirectLogoutState> { ) : Presenter<DirectLogoutState> {
@Composable @Composable
override fun present(): DirectLogoutState { override fun present(): DirectLogoutState {
@@ -41,13 +35,6 @@ class DirectLogoutPresenter(
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val backupUploadState: BackupUploadState by remember {
encryptionService.waitForBackupUploadSteadyState()
}
.collectAsState(initial = BackupUploadState.Unknown)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
fun handleEvent(event: DirectLogoutEvent) { fun handleEvent(event: DirectLogoutEvent) {
when (event) { when (event) {
is DirectLogoutEvent.Logout -> { is DirectLogoutEvent.Logout -> {
@@ -64,8 +51,6 @@ class DirectLogoutPresenter(
} }
return DirectLogoutState( return DirectLogoutState(
canDoDirectSignOut = !isLastDevice &&
!backupUploadState.isBackingUp(),
logoutAction = logoutAction.value, logoutAction = logoutAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -19,6 +19,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun LogoutActionDialog( fun LogoutActionDialog(
state: AsyncAction<Unit>, state: AsyncAction<Unit>,
showProgressDialog: Boolean,
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
onForceLogoutClick: () -> Unit, onForceLogoutClick: () -> Unit,
onDismissDialog: () -> Unit, onDismissDialog: () -> Unit,
@@ -31,8 +32,11 @@ fun LogoutActionDialog(
onSubmitClick = onConfirmClick, onSubmitClick = onConfirmClick,
onDismiss = onDismissDialog onDismiss = onDismissDialog
) )
is AsyncAction.Loading -> is AsyncAction.Loading -> {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) if (showProgressDialog) {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
}
is AsyncAction.Failure -> is AsyncAction.Failure ->
RetryDialog( RetryDialog(
title = stringResource(id = CommonStrings.dialog_title_error), title = stringResource(id = CommonStrings.dialog_title_error),
@@ -20,7 +20,9 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.BackupUploadState
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
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
@@ -39,11 +41,18 @@ class LogoutPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLogoutPresenter() val presenter = createLogoutPresenter(
matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,
userDisplayName = "Alice",
userAvatarUrl = "mxc://avatar",
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.currentUser).isEqualTo(MatrixUser(A_SESSION_ID, "Alice", "mxc://avatar"))
assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.isLastDevice).isFalse()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer).isTrue() assertThat(initialState.doesBackupExistOnServer).isTrue()
@@ -13,8 +13,11 @@ package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@@ -25,8 +28,22 @@ import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config
class LogoutViewTest : RobolectricTest() { class LogoutViewTest : RobolectricTest() {
@Test
@Config(qualifiers = "h1024dp")
fun `the current user is displayed`() = runAndroidComposeUiTest {
setLogoutView(
aLogoutState(
currentUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
eventSink = EventsRecorder(expectEvents = false),
),
)
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("@alice:server.org").assertIsDisplayed()
}
@Test @Test
fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest { fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvent>() val eventsRecorder = EventsRecorder<LogoutEvent>()
@@ -88,10 +105,24 @@ class LogoutViewTest : RobolectricTest() {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel, inDialog = true)
eventsRecorder.assertSingle(LogoutEvent.CloseDialogs) eventsRecorder.assertSingle(LogoutEvent.CloseDialogs)
} }
@Test
fun `clicking on cancel invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvent>(expectEvents = false)
ensureCalledOnce { callback ->
setLogoutView(
aLogoutState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
clickOn(CommonStrings.action_cancel)
}
}
@Test @Test
fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest { fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvent>(expectEvents = false)
@@ -16,13 +16,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -38,44 +34,6 @@ class DirectLogoutPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.canDoDirectSignOut).isTrue()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - initial state - last session`() = runTest {
val presenter = createDirectLogoutPresenter(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.canDoDirectSignOut).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService()
encryptionService.givenWaitForBackupUploadSteadyStateFlow(
flow {
emit(BackupUploadState.Waiting)
}
)
val presenter = createDirectLogoutPresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitFirstItem()
assertThat(initialState.canDoDirectSignOut).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
} }
} }
@@ -180,9 +138,7 @@ class DirectLogoutPresenterTest {
private fun createDirectLogoutPresenter( private fun createDirectLogoutPresenter(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
encryptionService: EncryptionService = FakeEncryptionService(),
): DirectLogoutPresenter = DirectLogoutPresenter( ): DirectLogoutPresenter = DirectLogoutPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
encryptionService = encryptionService,
) )
} }
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -34,7 +33,6 @@ class PreferencesAccountPresenter(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
@@ -65,8 +63,6 @@ class PreferencesAccountPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount() canDeactivateAccount = matrixClient.canDeactivateAccount()
} }
val directLogoutState = directLogoutPresenter.present()
val accountManagementUrl by produceState<String?>(initialValue = null) { val accountManagementUrl by produceState<String?>(initialValue = null) {
value = matrixClient.getAccountManagementUrl(null) value = matrixClient.getAccountManagementUrl(null)
.getOrNull() .getOrNull()
@@ -83,7 +79,6 @@ class PreferencesAccountPresenter(
canReportBug = canReportBug, canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice, showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount, canDeactivateAccount = canDeactivateAccount,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
} }
@@ -7,7 +7,6 @@
package io.element.android.features.preferences.impl.account package io.element.android.features.preferences.impl.account
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -19,6 +18,5 @@ data class PreferencesAccountState(
val canReportBug: Boolean, val canReportBug: Boolean,
val showLinkNewDevice: Boolean, val showLinkNewDevice: Boolean,
val canDeactivateAccount: Boolean, val canDeactivateAccount: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
) )
@@ -8,8 +8,6 @@
package io.element.android.features.preferences.impl.account package io.element.android.features.preferences.impl.account
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.designsystem.utils.snackbar.SnackbarMessage
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.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -56,7 +54,6 @@ fun aPreferencesAccountState(
canReportBug: Boolean = false, canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false, showLinkNewDevice: Boolean = false,
canDeactivateAccount: Boolean = false, canDeactivateAccount: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
) = PreferencesAccountState( ) = PreferencesAccountState(
myUser = myUser, myUser = myUser,
@@ -66,6 +63,5 @@ fun aPreferencesAccountState(
canReportBug = canReportBug, canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice, showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount, canDeactivateAccount = canDeactivateAccount,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
@@ -19,8 +19,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.preferences.impl.account.PreferencesAccountCallback import io.element.android.features.preferences.impl.account.PreferencesAccountCallback
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
@@ -34,7 +32,6 @@ class PreferencesRootNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter, private val presenter: PreferencesRootPresenter,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin { interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount() fun navigateToAddAccount()
@@ -89,16 +86,8 @@ class PreferencesRootNode(
onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings, onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile, onEditProfileClick = callback::navigateToUserProfile,
onSignOutClick = { onSignOutClick = callback::startSignOutFlow,
if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) {
state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onDeactivateClick = callback::startAccountDeactivationFlow onDeactivateClick = callback::startAccountDeactivationFlow
) )
directLogoutView.Render(state = state.preferencesAccountState.directLogoutState)
} }
} }
@@ -10,7 +10,6 @@ package io.element.android.features.preferences.impl.account
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -65,7 +64,6 @@ class PreferencesAccountPresenterTest {
assertThat(initialState.showSecureBackupBadge).isFalse() assertThat(initialState.showSecureBackupBadge).isFalse()
assertThat(initialState.accountManagementUrl).isNull() assertThat(initialState.accountManagementUrl).isNull()
assertThat(initialState.showLinkNewDevice).isFalse() assertThat(initialState.showLinkNewDevice).isFalse()
assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last()
assertThat(loadedState.showSecureBackup).isFalse() assertThat(loadedState.showSecureBackup).isFalse()
@@ -169,7 +167,6 @@ class PreferencesAccountPresenterTest {
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
indicatorService = indicatorService, indicatorService = indicatorService,
directLogoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
sessionEnterpriseService = sessionEnterpriseService, sessionEnterpriseService = sessionEnterpriseService,
@@ -31,6 +31,7 @@ enum class AvatarSize(val dp: Dp) {
UserHeader(96.dp), UserHeader(96.dp),
UserListItem(36.dp), UserListItem(36.dp),
AccountInfoUser(36.dp),
SelectedUser(52.dp), SelectedUser(52.dp),
SelectedRoom(56.dp), SelectedRoom(56.dp),
@@ -0,0 +1,192 @@
/*
* 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.ui.components.accountinfo
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
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.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5873-996
*/
@Composable
fun AccountInfoCard(
state: AccountInfoCardState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
.padding(20.dp),
) {
when (state.mode) {
AccountInfoCardMode.Simple -> Unit
is AccountInfoCardMode.DeviceVerification -> Unit
is AccountInfoCardMode.UserVerification -> {
Text(
// TODO
text = "User requesting verification:",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
AccountInfoCardUser(
matrixUser = state.matrixUser,
)
when (state.mode) {
AccountInfoCardMode.Simple -> Unit
is AccountInfoCardMode.DeviceVerification -> {
AccountInfoSeparator()
DeviceInfoRow(state.mode)
}
is AccountInfoCardMode.UserVerification -> {
AccountInfoSeparator()
AccountInfoCardUser(
matrixUser = state.mode.otherUser,
)
}
}
}
}
@Composable
private fun AccountInfoSeparator() {
HorizontalDivider(
modifier = Modifier.padding(vertical = 20.dp),
color = ElementTheme.colors.separatorPrimary,
)
}
@Composable
private fun DeviceInfoRow(
mode: AccountInfoCardMode.DeviceVerification,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RoundedIconAtom(
modifier = Modifier,
size = RoundedIconAtomSize.Big,
resourceId = CompoundDrawables.ic_compound_devices
)
Text(
text = mode.deviceName ?: mode.deviceId.value,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
// TODO i18n
label = "Signed in",
text = mode.signInFormattedTimestamp,
modifier = Modifier.weight(1f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = mode.deviceId.value,
modifier = Modifier.weight(1f),
spellText = true,
)
}
}
}
@Composable
private fun AccountInfoCardUser(matrixUser: MatrixUser) {
val subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.AccountInfoUser),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
)
// Id
subtext?.let {
Text(
text = subtext,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountInfoCardPreview(
@PreviewParameter(AccountInfoCardStatePreviewParam::class) state: AccountInfoCardState
) = ElementPreview {
// Add some padding to view the border
Box(modifier = Modifier.padding(16.dp)) {
AccountInfoCard(state)
}
}
@@ -0,0 +1,33 @@
/*
* 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.ui.components.accountinfo
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
data class AccountInfoCardState(
val matrixUser: MatrixUser,
val mode: AccountInfoCardMode,
)
@Immutable
sealed interface AccountInfoCardMode {
data object Simple : AccountInfoCardMode
data class UserVerification(
val otherUser: MatrixUser,
) : AccountInfoCardMode
// TODO Replace SessionDetailsView with this component
data class DeviceVerification(
val deviceName: String?,
val deviceId: DeviceId,
val signInFormattedTimestamp: String,
) : AccountInfoCardMode
}
@@ -0,0 +1,41 @@
/*
* 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.ui.components.accountinfo
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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
open class AccountInfoCardStatePreviewParam : PreviewParameterProvider<AccountInfoCardState> {
override val values: Sequence<AccountInfoCardState>
get() = sequenceOf(
anAccountInfoCardState(),
anAccountInfoCardState(aMatrixUser(displayName = null)),
anAccountInfoCardState(
mode = AccountInfoCardMode.UserVerification(
otherUser = aMatrixUser(displayName = "Other User"),
)
),
anAccountInfoCardState(
mode = AccountInfoCardMode.DeviceVerification(
deviceName = "My Device",
deviceId = DeviceId("DEVICEID"),
signInFormattedTimestamp = "2024-06-01 12:00:00",
)
),
)
}
private fun anAccountInfoCardState(
matrixUser: MatrixUser = aMatrixUser(),
mode: AccountInfoCardMode = AccountInfoCardMode.Simple,
) = AccountInfoCardState(
matrixUser = matrixUser,
mode = mode,
)