Signed out screen: add account info.

Display the account which has been signed out, using the `AccountInfoCard`. The data comes
from the session database, including the stored avatar thumbnail, since there is no Matrix
client available to load the avatar. `SignedOutState.signedOutSession` is now a non-null
`MatrixUser`, defaulting to a user with only the session id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-28 13:55:46 +02:00
parent a2b2c2360a
commit f453cdf2ac
8 changed files with 206 additions and 77 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
} }
@@ -19,8 +19,11 @@ 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.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
@@ -37,15 +40,16 @@ 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)
} }
} }
@@ -53,8 +57,15 @@ class SignedOutPresenter(
return SignedOutState( return SignedOutState(
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
signedOutSession = signedOutSession, signedOutMatrixUser = signedOutSession,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun SessionData.toMatrixUser() = MatrixUser(
userId = sessionId,
displayName = userDisplayName,
avatarUrl = userAvatarUrl,
avatarThumbnail = userAvatarData,
)
} }
@@ -8,10 +8,10 @@
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 appName: String,
val signedOutSession: SessionData?, val signedOutMatrixUser: MatrixUser,
val eventSink: (SignedOutEvent) -> Unit, val eventSink: (SignedOutEvent) -> Unit,
) )
@@ -9,44 +9,22 @@
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(
signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
eventSink: (SignedOutEvent) -> Unit = {},
) = SignedOutState(
appName = "AppName", appName = "AppName",
signedOutSession = aSessionData(), signedOutMatrixUser = signedOutSession,
eventSink = {}, 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,7 +9,9 @@
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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
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
@@ -31,25 +33,32 @@ 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=795-6095
*/
@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) }, header = { SignedOutHeader(state) },
content = { SignedOutContent() }, content = { SignedOutContent(state.signedOutMatrixUser) },
footer = { footer = {
SignedOutFooter( SignedOutFooter(
onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) }, onSubmit = { state.eventSink(SignedOutEvent.Submit) },
) )
} }
) )
@@ -61,46 +70,64 @@ private fun SignedOutHeader(state: SignedOutState) {
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, state.appName),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), iconStyle = BigIcon.Style.Default(
CompoundIcons.UserProfileSolid(),
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(16.dp),
) { ) {
VisualList( AccountInfoCard(
items = persistentListOf( AccountInfoCardState(
VisualListItemData( matrixUser = signedOutMatrixUser,
message = stringResource(id = R.string.screen_signed_out_reason_1), mode = AccountInfoCardMode.Simple,
iconVector = CompoundIcons.Lock(), )
),
VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_2),
iconVector = CompoundIcons.Devices(),
),
VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_3),
iconVector = CompoundIcons.Block(),
),
),
) )
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAlignment(
horizontalBias = 0f,
verticalBias = -0.4f
)
) {
VisualList(
items = persistentListOf(
VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_1),
iconVector = CompoundIcons.Lock(),
),
VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_2),
iconVector = CompoundIcons.Devices(),
),
VisualListItemData(
message = stringResource(id = R.string.screen_signed_out_reason_3),
iconVector = CompoundIcons.Block(),
),
),
)
}
} }
} }
@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(),
) )
} }
@@ -13,6 +13,7 @@ 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.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME
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.matrix.test.core.aBuildMeta
@@ -20,6 +21,8 @@ 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 +33,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 +46,27 @@ 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.appName).isEqualTo(AN_APPLICATION_NAME)
assertThat(initialState.signedOutSession).isEqualTo(aSessionData) assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID))
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 +76,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()
} }
} }
} }
@@ -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,
)
}
}