Merge pull request #7822 from element-hq/feature/bma/signedOutScreen

Update signed out screen
This commit is contained in:
Benoit Marty
2026-09-30 15:41:21 +02:00
committed by GitHub
13 changed files with 204 additions and 86 deletions
+7 -1
View File
@@ -16,6 +16,12 @@ plugins {
android { android {
namespace = "io.element.android.features.signedout.impl" namespace = "io.element.android.features.signedout.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
setupDependencyInjection() setupDependencyInjection()
@@ -30,7 +36,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
testCommonDependencies(libs) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
} }
@@ -9,5 +9,5 @@
package io.element.android.features.signedout.impl package io.element.android.features.signedout.impl
sealed interface SignedOutEvent { sealed interface SignedOutEvent {
data object SignInAgain : SignedOutEvent data object Submit : SignedOutEvent
} }
@@ -17,17 +17,18 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class SignedOutPresenter( class SignedOutPresenter(
@Assisted private val sessionId: SessionId, @Assisted private val sessionId: SessionId,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val buildMeta: BuildMeta,
) : Presenter<SignedOutState> { ) : Presenter<SignedOutState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -37,24 +38,31 @@ class SignedOutPresenter(
@Composable @Composable
override fun present(): SignedOutState { override fun present(): SignedOutState {
val signedOutSession by remember { val signedOutSession by remember {
sessionStore.sessionsFlow().map { sessions -> sessionStore.sessionsFlow()
sessions.firstOrNull { it.userId == sessionId.value } // Ignore the removal of the session, to keep rendering the account data until the screen is closed
} .mapNotNull { sessions -> sessions.firstOrNull { it.userId == sessionId.value } }
}.collectAsState(initial = null) .map { it.toMatrixUser() }
}.collectAsState(initial = MatrixUser(userId = sessionId))
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: SignedOutEvent) { fun handleEvent(event: SignedOutEvent) {
when (event) { when (event) {
SignedOutEvent.SignInAgain -> coroutineScope.launch { SignedOutEvent.Submit -> coroutineScope.launch {
sessionStore.removeSession(sessionId.value) sessionStore.removeSession(sessionId.value)
} }
} }
} }
return SignedOutState( return SignedOutState(
appName = buildMeta.applicationName, signedOutMatrixUser = signedOutSession,
signedOutSession = signedOutSession,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun SessionData.toMatrixUser() = MatrixUser(
userId = sessionId,
displayName = userDisplayName,
avatarUrl = userAvatarUrl,
avatarThumbnail = userAvatarData,
)
} }
@@ -8,10 +8,9 @@
package io.element.android.features.signedout.impl package io.element.android.features.signedout.impl
import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.matrix.api.user.MatrixUser
data class SignedOutState( data class SignedOutState(
val appName: String, val signedOutMatrixUser: MatrixUser,
val signedOutSession: SessionData?,
val eventSink: (SignedOutEvent) -> Unit, val eventSink: (SignedOutEvent) -> Unit,
) )
@@ -9,44 +9,21 @@
package io.element.android.features.signedout.impl package io.element.android.features.signedout.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.matrix.ui.components.aMatrixUser
open class SignedOutStatePreviewParam : PreviewParameterProvider<SignedOutState> { open class SignedOutStatePreviewParam : PreviewParameterProvider<SignedOutState> {
override val values: Sequence<SignedOutState> override val values: Sequence<SignedOutState>
get() = sequenceOf( get() = sequenceOf(
aSignedOutState(), aSignedOutState(),
// Add other states here aSignedOutState(signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null)),
) )
} }
private fun aSignedOutState() = SignedOutState( internal fun aSignedOutState(
appName = "AppName", signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
signedOutSession = aSessionData(), eventSink: (SignedOutEvent) -> Unit = {},
eventSink = {}, ) = SignedOutState(
signedOutMatrixUser = signedOutSession,
eventSink = eventSink,
) )
private fun aSessionData(
sessionId: String = "@alice:server.org",
isTokenValid: Boolean = false,
): SessionData {
return SessionData(
userId = sessionId,
deviceId = "aDeviceId",
accessToken = "anAccessToken",
refreshToken = "aRefreshToken",
homeserverUrl = "aHomeserverUrl",
oAuthData = null,
loginTimestamp = null,
isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN,
passphrase = null,
sessionPath = "/a/path/to/a/session",
cachePath = "/a/path/to/a/cache",
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
userAvatarData = null,
)
}
@@ -9,14 +9,14 @@
package io.element.android.features.signedout.impl package io.element.android.features.signedout.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -31,58 +31,76 @@ import io.element.android.libraries.designsystem.components.visuallist.VisualLis
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.matrix.api.user.MatrixUser
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.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=856-6104
*/
@Composable @Composable
fun SignedOutView( fun SignedOutView(
state: SignedOutState, state: SignedOutState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(onBack = { state.eventSink(SignedOutEvent.SignInAgain) }) BackHandler(onBack = { state.eventSink(SignedOutEvent.Submit) })
HeaderFooterPage( HeaderFooterPage(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.systemBarsPadding() .systemBarsPadding()
.imePadding(), .imePadding(),
header = { SignedOutHeader(state) }, isScrollable = true,
content = { SignedOutContent() }, header = { SignedOutHeader() },
content = { SignedOutContent(state.signedOutMatrixUser) },
footer = { footer = {
SignedOutFooter( SignedOutFooter(
onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) }, onSubmit = { state.eventSink(SignedOutEvent.Submit) },
) )
} }
) )
} }
@Composable @Composable
private fun SignedOutHeader(state: SignedOutState) { private fun SignedOutHeader() {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
title = stringResource(id = R.string.screen_signed_out_title), title = stringResource(id = R.string.screen_signed_out_title),
subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), subTitle = stringResource(id = R.string.screen_signed_out_subtitle_v2),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), iconStyle = BigIcon.Style.Default(
CompoundIcons.Devices(),
usePrimaryTint = true,
),
) )
} }
@Composable @Composable
private fun SignedOutContent() { private fun SignedOutContent(
Box( signedOutMatrixUser: MatrixUser,
modifier = Modifier.fillMaxSize(), ) {
contentAlignment = BiasAlignment( Column(
horizontalBias = 0f, modifier = Modifier
verticalBias = -0.4f .fillMaxSize()
) .padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
AccountInfoCard(
AccountInfoCardState(
matrixUser = signedOutMatrixUser,
mode = AccountInfoCardMode.Simple,
)
)
VisualList( VisualList(
items = persistentListOf( items = persistentListOf(
VisualListItemData( VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_1), message = stringResource(id = R.string.screen_signed_out_reason_1),
iconVector = CompoundIcons.Lock(), iconVector = CompoundIcons.UserProfile(),
), ),
VisualListItemData( VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_2), message = stringResource(id = R.string.screen_signed_out_reason_2),
iconVector = CompoundIcons.Devices(), iconVector = CompoundIcons.Lock(),
), ),
VisualListItemData( VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_3), message = stringResource(id = R.string.screen_signed_out_reason_3),
@@ -95,12 +113,12 @@ private fun SignedOutContent() {
@Composable @Composable
private fun SignedOutFooter( private fun SignedOutFooter(
onSignInAgain: () -> Unit, onSubmit: () -> Unit,
) { ) {
ButtonColumnMolecule { ButtonColumnMolecule {
Button( Button(
text = stringResource(id = CommonStrings.action_sign_in_again), text = stringResource(id = CommonStrings.action_ok),
onClick = onSignInAgain, onClick = onSubmit,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signed_out_reason_1">"You’ve changed your password on another session"</string> <string name="screen_signed_out_reason_1">"You removed this device from your account"</string>
<string name="screen_signed_out_reason_2">"You have deleted the session from another session"</string> <string name="screen_signed_out_reason_2">"You’ve changed your password on another device"</string>
<string name="screen_signed_out_reason_3">"Your server’s administrator has invalidated your access"</string> <string name="screen_signed_out_reason_3">"Your server\'s session expired or the administrator has blocked your access"</string>
<string name="screen_signed_out_subtitle">"You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s."</string> <string name="screen_signed_out_subtitle">"You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s."</string>
<string name="screen_signed_out_title">"You’re signed out"</string> <string name="screen_signed_out_subtitle_v2">"This device was removed for one of the reasons listed below. Please sign in again to connect this device to your account."</string>
<string name="screen_signed_out_title">"Device removed"</string>
</resources> </resources>
@@ -13,13 +13,14 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
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
@@ -30,7 +31,12 @@ class SignedOutPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val aSessionData = aSessionData() val aSessionData = aSessionData(
sessionId = A_SESSION_ID.value,
userDisplayName = "Alice",
userAvatarUrl = "avatarUrl",
userAvatarData = "avatarData",
)
val sessionStore = InMemorySessionStore( val sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData) initialList = listOf(aSessionData)
) )
@@ -38,16 +44,26 @@ class SignedOutPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID))
assertThat(initialState.signedOutSession).isEqualTo(aSessionData) assertThat(awaitItem().signedOutMatrixUser).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = "Alice",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
)
)
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - sign in again`() = runTest { fun `present - sign in again`() = runTest {
val aSessionData = aSessionData() val aSessionData = aSessionData(
sessionId = A_SESSION_ID.value,
userDisplayName = "Alice",
)
val sessionStore = InMemorySessionStore( val sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData) initialList = listOf(aSessionData)
) )
@@ -57,13 +73,15 @@ class SignedOutPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.signedOutSession).isEqualTo(aSessionData) assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID, displayName = "Alice"))
assertThat(sessionStore.getAllSessions()).isNotEmpty() assertThat(sessionStore.getAllSessions()).isNotEmpty()
assertThat(sessionStore.numberOfSessions()).isEqualTo(1) assertThat(sessionStore.numberOfSessions()).isEqualTo(1)
initialState.eventSink(SignedOutEvent.SignInAgain) initialState.eventSink(SignedOutEvent.Submit)
assertThat(awaitItem().signedOutSession).isNull() runCurrent()
assertThat(sessionStore.getAllSessions()).isEmpty() assertThat(sessionStore.getAllSessions()).isEmpty()
assertThat(sessionStore.numberOfSessions()).isEqualTo(0) assertThat(sessionStore.numberOfSessions()).isEqualTo(0)
// The removal of the session must not change the rendered data
expectNoEvents()
} }
} }
} }
@@ -75,6 +93,5 @@ internal fun createSignedOutPresenter(
return SignedOutPresenter( return SignedOutPresenter(
sessionId = sessionId, sessionId = sessionId,
sessionStore = sessionStore, sessionStore = sessionStore,
buildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
) )
} }
@@ -0,0 +1,86 @@
/*
* 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.signedout.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.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.annotation.Config
class SignedOutViewTest : RobolectricTest() {
@Test
@Config(qualifiers = "h1024dp")
fun `the signed out user is displayed`() = runAndroidComposeUiTest {
setSignedOutView(
aSignedOutState(
signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
eventSink = EventsRecorder(expectEvents = false),
),
)
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("@alice:server.org").assertIsDisplayed()
}
@Test
@Config(qualifiers = "h1024dp")
fun `the signed out user id is displayed when there is no display name`() = runAndroidComposeUiTest {
setSignedOutView(
aSignedOutState(
signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null),
eventSink = EventsRecorder(expectEvents = false),
),
)
onNodeWithText("@alice:server.org").assertIsDisplayed()
}
@Test
fun `clicking on OK sends a Submit event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<SignedOutEvent>()
setSignedOutView(
aSignedOutState(
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(SignedOutEvent.Submit)
}
@Test
fun `pressing back sends a Submit event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<SignedOutEvent>()
setSignedOutView(
aSignedOutState(
eventSink = eventsRecorder,
),
)
pressBackKey()
eventsRecorder.assertSingle(SignedOutEvent.Submit)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setSignedOutView(
state: SignedOutState,
) {
setContent {
SignedOutView(
state = state,
)
}
}