diff --git a/features/signedout/impl/build.gradle.kts b/features/signedout/impl/build.gradle.kts index b3801288be..ed5dec3eb9 100644 --- a/features/signedout/impl/build.gradle.kts +++ b/features/signedout/impl/build.gradle.kts @@ -16,6 +16,12 @@ plugins { android { namespace = "io.element.android.features.signedout.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupDependencyInjection() @@ -30,7 +36,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - testCommonDependencies(libs) + testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt index 1e774488e5..39e22ad434 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt @@ -9,5 +9,5 @@ package io.element.android.features.signedout.impl sealed interface SignedOutEvent { - data object SignInAgain : SignedOutEvent + data object Submit : SignedOutEvent } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt index 1f1a9be1d7..1f35da352a 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -17,17 +17,18 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject 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.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @AssistedInject class SignedOutPresenter( @Assisted private val sessionId: SessionId, private val sessionStore: SessionStore, - private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory fun interface Factory { @@ -37,24 +38,31 @@ class SignedOutPresenter( @Composable override fun present(): SignedOutState { val signedOutSession by remember { - sessionStore.sessionsFlow().map { sessions -> - sessions.firstOrNull { it.userId == sessionId.value } - } - }.collectAsState(initial = null) + sessionStore.sessionsFlow() + // 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 } } + .map { it.toMatrixUser() } + }.collectAsState(initial = MatrixUser(userId = sessionId)) val coroutineScope = rememberCoroutineScope() fun handleEvent(event: SignedOutEvent) { when (event) { - SignedOutEvent.SignInAgain -> coroutineScope.launch { + SignedOutEvent.Submit -> coroutineScope.launch { sessionStore.removeSession(sessionId.value) } } } return SignedOutState( - appName = buildMeta.applicationName, - signedOutSession = signedOutSession, + signedOutMatrixUser = signedOutSession, eventSink = ::handleEvent, ) } + + private fun SessionData.toMatrixUser() = MatrixUser( + userId = sessionId, + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + avatarThumbnail = userAvatarData, + ) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt index aeff7c7d2f..c6c4d2a2c9 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt @@ -8,10 +8,9 @@ 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( - val appName: String, - val signedOutSession: SessionData?, + val signedOutMatrixUser: MatrixUser, val eventSink: (SignedOutEvent) -> Unit, ) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt index e0396cba97..d83ebfe7d7 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt @@ -9,44 +9,21 @@ package io.element.android.features.signedout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.sessionstorage.api.LoginType -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class SignedOutStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSignedOutState(), - // Add other states here + aSignedOutState(signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null)), ) } -private fun aSignedOutState() = SignedOutState( - appName = "AppName", - signedOutSession = aSessionData(), - eventSink = {}, +internal fun aSignedOutState( + signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink: (SignedOutEvent) -> Unit = {}, +) = 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, - ) -} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt index 73e854c836..a381c27252 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt @@ -9,14 +9,14 @@ package io.element.android.features.signedout.impl 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.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable -import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.PreviewsDayNight 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 kotlinx.collections.immutable.persistentListOf +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=856-6104 + */ @Composable fun SignedOutView( state: SignedOutState, modifier: Modifier = Modifier, ) { - BackHandler(onBack = { state.eventSink(SignedOutEvent.SignInAgain) }) + BackHandler(onBack = { state.eventSink(SignedOutEvent.Submit) }) HeaderFooterPage( modifier = modifier .fillMaxSize() .systemBarsPadding() .imePadding(), - header = { SignedOutHeader(state) }, - content = { SignedOutContent() }, + isScrollable = true, + header = { SignedOutHeader() }, + content = { SignedOutContent(state.signedOutMatrixUser) }, footer = { SignedOutFooter( - onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) }, + onSubmit = { state.eventSink(SignedOutEvent.Submit) }, ) } ) } @Composable -private fun SignedOutHeader(state: SignedOutState) { +private fun SignedOutHeader() { IconTitleSubtitleMolecule( modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), title = stringResource(id = R.string.screen_signed_out_title), - subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), - iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + subTitle = stringResource(id = R.string.screen_signed_out_subtitle_v2), + iconStyle = BigIcon.Style.Default( + CompoundIcons.Devices(), + usePrimaryTint = true, + ), ) } @Composable -private fun SignedOutContent() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BiasAlignment( - horizontalBias = 0f, - verticalBias = -0.4f - ) +private fun SignedOutContent( + signedOutMatrixUser: MatrixUser, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + AccountInfoCard( + AccountInfoCardState( + matrixUser = signedOutMatrixUser, + mode = AccountInfoCardMode.Simple, + ) + ) VisualList( items = persistentListOf( VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_1), - iconVector = CompoundIcons.Lock(), + iconVector = CompoundIcons.UserProfile(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_2), - iconVector = CompoundIcons.Devices(), + iconVector = CompoundIcons.Lock(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_3), @@ -95,12 +113,12 @@ private fun SignedOutContent() { @Composable private fun SignedOutFooter( - onSignInAgain: () -> Unit, + onSubmit: () -> Unit, ) { ButtonColumnMolecule { Button( - text = stringResource(id = CommonStrings.action_sign_in_again), - onClick = onSignInAgain, + text = stringResource(id = CommonStrings.action_ok), + onClick = onSubmit, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/signedout/impl/src/main/res/values/localazy.xml b/features/signedout/impl/src/main/res/values/localazy.xml index f70b0042d5..7b221a18f2 100644 --- a/features/signedout/impl/src/main/res/values/localazy.xml +++ b/features/signedout/impl/src/main/res/values/localazy.xml @@ -1,8 +1,9 @@ - "You’ve changed your password on another session" - "You have deleted the session from another session" - "Your server’s administrator has invalidated your access" + "You removed this device from your account" + "You’ve changed your password on another device" + "Your server\'s session expired or the administrator has blocked your access" "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s." - "You’re signed out" + "This device was removed for one of the reasons listed below. Please sign in again to connect this device to your account." + "Device removed" diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt index b72ebbcdba..5f15c9473c 100644 --- a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -13,13 +13,14 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat 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.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -30,7 +31,12 @@ class SignedOutPresenterTest { @Test 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( initialList = listOf(aSessionData) ) @@ -38,16 +44,26 @@ class SignedOutPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() - 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 fun `present - sign in again`() = runTest { - val aSessionData = aSessionData() + val aSessionData = aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = "Alice", + ) val sessionStore = InMemorySessionStore( initialList = listOf(aSessionData) ) @@ -57,13 +73,15 @@ class SignedOutPresenterTest { }.test { skipItems(1) 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.numberOfSessions()).isEqualTo(1) - initialState.eventSink(SignedOutEvent.SignInAgain) - assertThat(awaitItem().signedOutSession).isNull() + initialState.eventSink(SignedOutEvent.Submit) + runCurrent() assertThat(sessionStore.getAllSessions()).isEmpty() 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( sessionId = sessionId, sessionStore = sessionStore, - buildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), ) } diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt new file mode 100644 index 0000000000..f9c2438ea3 --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt @@ -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() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } + + @Test + fun `pressing back sends a Submit event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + pressBackKey() + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } +} + +private fun AndroidComposeUiTest.setSignedOutView( + state: SignedOutState, +) { + setContent { + SignedOutView( + state = state, + ) + } +} diff --git a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_0_en.png index 624595d341..ab4aee53d0 100644 --- a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7415c66aa6d4192cab207f9fe7e9c3c91a44b7026829db1978a0564586b129da -size 55706 +oid sha256:e253c2d3edbe964d17b30c276d73743d37c9d0fcbe33e3a7abb24b6460825b52 +size 56594 diff --git a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_1_en.png new file mode 100644 index 0000000000..a1e725e1c1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29fc8ab0b46e559834cb5d1a032719f2a9a1aeaa557ebfc5073206737954d75f +size 59134 diff --git a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_0_en.png index cc90c66c1c..29e365c610 100644 --- a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85cc985b64929bf7faf35b4c2bf63bbccf5175cc024ba4249cc0e90a60be80be -size 54652 +oid sha256:5f3058aebd3f195351ab684930cd7b09c67c8c82f67339f75c3c64b040ee14ce +size 55344 diff --git a/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_1_en.png new file mode 100644 index 0000000000..1917ffe636 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.signedout.impl_SignedOutView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0b3a5ba7666e7fccdfddfe5f2ad58cd3211dafa1ae25d7deb1f57a2bcea9144 +size 57918