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:
id: "home_screen-settings"
- tapOn:
id: "settings-user_profile"
- assertVisible: "Your account"
- tapOn: "Remove this device"
- takeScreenshot: build/maestro/900-SignOutScreen
- back
- tapOn: "Remove this device"
# Ensure pressing back cancels
- back
- tapOn: "Remove this device"
# Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Remove this device"
- tapOn:
text: "Remove this device"
index: 1
# Cancel the dialog cancels the logout
- tapOn:
id: "dialog-negative"
- tapOn: "Remove this device"
- tapOn:
text: "Remove this device"
index: 1
- tapOn:
id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml
-2
View File
@@ -53,7 +53,6 @@ dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api)
@@ -67,7 +66,6 @@ dependencies {
testCommonDependencies(libs, true)
testImplementation(projects.features.announcement.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.matrix.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.declineandblock.DeclineInviteAndBlockEntryPoint
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.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
@@ -83,7 +82,6 @@ class HomeFlowNode(
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
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 io.element.android.features.home.impl.roomlist.RoomListState
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.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -42,7 +41,6 @@ class HomePresenter(
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
) : Presenter<HomeState> {
@@ -75,7 +73,6 @@ class HomePresenter(
}
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present()
fun handleEvent(event: HomeEvent) {
when (event) {
@@ -98,7 +95,6 @@ class HomePresenter(
homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
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.spacefilters.SpaceFiltersState
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.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@@ -29,7 +28,6 @@ data class HomeState(
val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
val eventSink: (HomeEvent) -> Unit,
) {
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.spaces.HomeSpacesState
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.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -63,7 +61,6 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -71,7 +68,6 @@ internal fun aHomeState(
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
@@ -36,7 +36,6 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
inviteFriendsUseCase = { lambdaError() },
analyticsService = FakeAnalyticsService(),
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
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.spaces.HomeSpacesState
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.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -162,7 +161,6 @@ internal fun createHomePresenter(
indicatorService = indicatorService,
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
)
@@ -11,7 +11,6 @@ package io.element.android.features.logout.api.direct
import io.element.android.libraries.architecture.AsyncAction
data class DirectLogoutState(
val canDoDirectSignOut: Boolean,
val logoutAction: AsyncAction<Unit>,
val eventSink: (DirectLogoutEvent) -> Unit,
)
@@ -23,11 +23,9 @@ open class DirectLogoutStatePreviewParam : PreviewParameterProvider<DirectLogout
}
fun aDirectLogoutState(
canDoDirectSignOut: Boolean = true,
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (DirectLogoutEvent) -> Unit = {},
) = DirectLogoutState(
canDoDirectSignOut = canDoDirectSignOut,
logoutAction = logoutAction,
eventSink = eventSink,
)
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
@@ -41,6 +41,7 @@ class LogoutPresenter(
@Composable
override fun present(): LogoutState {
val localCoroutineScope = rememberCoroutineScope()
val currentUser by matrixClient.userProfile.collectAsState()
val logoutAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized)
}
@@ -90,6 +91,7 @@ class LogoutPresenter(
}
return LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice,
backupState = backupState,
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.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
data class LogoutState(
val currentUser: MatrixUser,
val isLastDevice: Boolean,
val backupState: BackupState,
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.RecoveryState
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> {
override val values: Sequence<LogoutState>
@@ -39,10 +41,12 @@ open class LogoutStatePreviewParam : PreviewParameterProvider<LogoutState> {
backupUploadState = BackupUploadState.Waiting,
waitingForALongTime = true,
),
aLogoutState(currentUser = aMatrixUser(id = "@alice:server.org", displayName = null)),
)
}
fun aLogoutState(
currentUser: MatrixUser = aMatrixUser(),
isLastDevice: Boolean = false,
backupState: BackupState = BackupState.ENABLED,
doesBackupExistOnServer: Boolean = true,
@@ -52,6 +56,7 @@ fun aLogoutState(
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LogoutEvent) -> Unit = {},
) = LogoutState(
currentUser = currentUser,
isLastDevice = isLastDevice,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer,
@@ -11,7 +11,9 @@ package io.element.android.features.logout.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.runtime.Composable
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.OutlinedButton
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.matrix.api.encryption.BackupState
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.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.testTag
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
fun LogoutView(
state: LogoutState,
@@ -54,15 +63,17 @@ fun LogoutView(
onBackClick = onBackClick,
title = title(state),
subTitle = subtitle(state),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
iconStyle = BigIcon.Style.Default(CompoundIcons.Mobile()),
modifier = modifier,
isScrollable = true,
buttons = {
Buttons(
state = state,
onChangeRecoveryKeyClick = onChangeRecoveryKeyClick,
onLogoutClick = {
eventSink(LogoutEvent.Logout(ignoreSdkError = false))
}
},
onCancelClick = onBackClick,
)
},
) {
@@ -71,6 +82,8 @@ fun LogoutView(
LogoutActionDialog(
state.logoutAction,
// Do not show the progress dialog, the progress is rendered in the button.
showProgressDialog = false,
onConfirmClick = {
eventSink(LogoutEvent.Logout(ignoreSdkError = false))
},
@@ -116,6 +129,7 @@ private fun ColumnScope.Buttons(
state: LogoutState,
onLogoutClick: () -> Unit,
onChangeRecoveryKeyClick: () -> Unit,
onCancelClick: () -> Unit,
) {
val logoutAction = state.logoutAction
if (state.isLastDevice) {
@@ -139,6 +153,12 @@ private fun ColumnScope.Buttons(
.testTag(TestTags.signOut),
onClick = onLogoutClick,
)
TextButton(
text = stringResource(id = CommonStrings.action_cancel),
enabled = logoutAction !is AsyncAction.Loading,
modifier = Modifier.fillMaxWidth(),
onClick = onCancelClick,
)
}
@Composable
@@ -149,9 +169,16 @@ private fun Content(
Column(
modifier = modifier
.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)
) {
AccountInfoCard(
AccountInfoCardState(
matrixUser = state.currentUser,
mode = AccountInfoCardMode.Simple,
)
)
Spacer(modifier = Modifier.height(16.dp))
when (state.backupUploadState) {
is BackupUploadState.Uploading -> {
LinearProgressIndicator(
@@ -27,6 +27,7 @@ class DefaultDirectLogoutView : DirectLogoutView {
val eventSink = state.eventSink
LogoutActionDialog(
state.logoutAction,
showProgressDialog = true,
onConfirmClick = {
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.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.features.logout.api.direct.DirectLogoutEvent
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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
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.launch
@Inject
class DirectLogoutPresenter(
private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService,
) : Presenter<DirectLogoutState> {
@Composable
override fun present(): DirectLogoutState {
@@ -41,13 +35,6 @@ class DirectLogoutPresenter(
mutableStateOf(AsyncAction.Uninitialized)
}
val backupUploadState: BackupUploadState by remember {
encryptionService.waitForBackupUploadSteadyState()
}
.collectAsState(initial = BackupUploadState.Unknown)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
fun handleEvent(event: DirectLogoutEvent) {
when (event) {
is DirectLogoutEvent.Logout -> {
@@ -64,8 +51,6 @@ class DirectLogoutPresenter(
}
return DirectLogoutState(
canDoDirectSignOut = !isLastDevice &&
!backupUploadState.isBackingUp(),
logoutAction = logoutAction.value,
eventSink = ::handleEvent,
)
@@ -19,6 +19,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LogoutActionDialog(
state: AsyncAction<Unit>,
showProgressDialog: Boolean,
onConfirmClick: () -> Unit,
onForceLogoutClick: () -> Unit,
onDismissDialog: () -> Unit,
@@ -31,8 +32,11 @@ fun LogoutActionDialog(
onSubmitClick = onConfirmClick,
onDismiss = onDismissDialog
)
is AsyncAction.Loading ->
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
is AsyncAction.Loading -> {
if (showProgressDialog) {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
}
is AsyncAction.Failure ->
RetryDialog(
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.EncryptionService
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.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
@@ -39,11 +41,18 @@ class LogoutPresenterTest {
@Test
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) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.currentUser).isEqualTo(MatrixUser(A_SESSION_ID, "Alice", "mxc://avatar"))
assertThat(initialState.isLastDevice).isFalse()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer).isTrue()
@@ -13,8 +13,11 @@ package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
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 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.ui.strings.CommonStrings
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.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.annotation.Config
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
fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvent>()
@@ -88,10 +105,24 @@ class LogoutViewTest : RobolectricTest() {
eventSink = eventsRecorder
),
)
clickOn(CommonStrings.action_cancel)
clickOn(CommonStrings.action_cancel, inDialog = true)
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
fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest {
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.libraries.architecture.AsyncAction
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.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -38,44 +34,6 @@ class DirectLogoutPresenterTest {
presenter.present()
}.test {
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)
}
}
@@ -180,9 +138,7 @@ class DirectLogoutPresenterTest {
private fun createDirectLogoutPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
encryptionService: EncryptionService = FakeEncryptionService(),
): DirectLogoutPresenter = DirectLogoutPresenter(
matrixClient = matrixClient,
encryptionService = encryptionService,
)
}
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
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.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -34,7 +33,6 @@ class PreferencesAccountPresenter(
private val sessionVerificationService: SessionVerificationService,
private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionEnterpriseService: SessionEnterpriseService,
@@ -65,8 +63,6 @@ class PreferencesAccountPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val directLogoutState = directLogoutPresenter.present()
val accountManagementUrl by produceState<String?>(initialValue = null) {
value = matrixClient.getAccountManagementUrl(null)
.getOrNull()
@@ -83,7 +79,6 @@ class PreferencesAccountPresenter(
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
)
}
@@ -7,7 +7,6 @@
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.matrix.api.user.MatrixUser
@@ -19,6 +18,5 @@ data class PreferencesAccountState(
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val canDeactivateAccount: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
)
@@ -8,8 +8,6 @@
package io.element.android.features.preferences.impl.account
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -56,7 +54,6 @@ fun aPreferencesAccountState(
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
canDeactivateAccount: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null,
) = PreferencesAccountState(
myUser = myUser,
@@ -66,6 +63,5 @@ fun aPreferencesAccountState(
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
)
@@ -19,8 +19,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
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.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
@@ -34,7 +32,6 @@ class PreferencesRootNode(
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
@@ -89,16 +86,8 @@ class PreferencesRootNode(
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile,
onSignOutClick = {
if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) {
state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onSignOutClick = callback::startSignOutFlow,
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 io.element.android.features.enterprise.api.SessionEnterpriseService
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.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -65,7 +64,6 @@ class PreferencesAccountPresenterTest {
assertThat(initialState.showSecureBackupBadge).isFalse()
assertThat(initialState.accountManagementUrl).isNull()
assertThat(initialState.showLinkNewDevice).isFalse()
assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(initialState.snackbarMessage).isNull()
val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last()
assertThat(loadedState.showSecureBackup).isFalse()
@@ -169,7 +167,6 @@ class PreferencesAccountPresenterTest {
sessionVerificationService = sessionVerificationService,
snackbarDispatcher = SnackbarDispatcher(),
indicatorService = indicatorService,
directLogoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
sessionEnterpriseService = sessionEnterpriseService,
@@ -31,6 +31,7 @@ enum class AvatarSize(val dp: Dp) {
UserHeader(96.dp),
UserListItem(36.dp),
AccountInfoUser(36.dp),
SelectedUser(52.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,
)