Show the account receiving the device verification request

Replace SessionDetailsView by AccountInfoCard with the DeviceVerification mode
on the incoming device verification screen, so that the account concerned by
the request is clearly indicated, with its avatar and Matrix ID.

The current user is built from the SessionStore, including the locally stored
avatar thumbnail, used as a fallback when the avatar cannot be loaded, for
instance for an account which is not the current one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-28 17:23:59 +02:00
parent 7502ab16c5
commit e6750dd301
10 changed files with 94 additions and 111 deletions
@@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings)
implementation(projects.features.logout.api)
api(libs.statemachine)
@@ -44,4 +45,5 @@ dependencies {
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -45,6 +50,8 @@ class IncomingVerificationPresenter(
@Assisted private val navigator: IncomingVerificationNavigator,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService,
private val sessionId: SessionId,
private val sessionStore: SessionStore,
private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: DateFormatter,
) : Presenter<IncomingVerificationState> {
@@ -92,9 +99,24 @@ class IncomingVerificationPresenter(
mode = DateFormatterMode.TimeOrDate,
)
}
val currentUser by remember {
sessionStore.sessionsFlow().mapNotNull { sessions ->
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
MatrixUser(
userId = sessionId,
displayName = sessionData.userDisplayName,
avatarUrl = sessionData.userAvatarUrl,
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
// for instance for a session which is not the current one
avatarThumbnail = sessionData.userAvatarData,
)
}
}
}.collectAsState(MatrixUser(sessionId))
val step by remember {
derivedStateOf {
stateAndDispatch.state.value.toVerificationStep(
currentUser = currentUser,
sessionVerificationRequestDetails = verificationRequest.details,
formattedSignInTime = formattedSignInTime,
)
@@ -147,6 +169,7 @@ class IncomingVerificationPresenter(
}
private fun StateMachineState?.toVerificationStep(
currentUser: MatrixUser,
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
formattedSignInTime: String,
): Step =
@@ -156,6 +179,7 @@ class IncomingVerificationPresenter(
StateMachineState.RejectingIncomingVerification,
null -> {
Step.Initial(
currentUser = currentUser,
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime,
@@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@@ -21,6 +22,7 @@ data class IncomingVerificationState(
@Stable
sealed interface Step {
data class Initial(
val currentUser: MatrixUser,
val deviceDisplayName: String?,
val deviceId: DeviceId,
val formattedSignInTime: String,
@@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
@@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
internal fun aStepInitial(
isWaiting: Boolean = false,
) = Step.Initial(
currentUser = MatrixUser(
userId = UserId("@bob:example.com"),
displayName = USER_NAME_BOB,
avatarUrl = null,
),
deviceDisplayName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
formattedSignInTime = "12:34",
@@ -31,7 +31,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
@@ -47,6 +46,9 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
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
/**
@@ -173,10 +175,15 @@ private fun ContentInitial(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SessionDetailsView(
deviceName = stepInitial.deviceDisplayName,
deviceId = stepInitial.deviceId,
signInFormattedTimestamp = stepInitial.formattedSignInTime,
AccountInfoCard(
state = AccountInfoCardState(
matrixUser = stepInitial.currentUser,
mode = AccountInfoCardMode.DeviceVerification(
deviceName = stepInitial.deviceDisplayName,
deviceId = stepInitial.deviceId,
signInFormattedTimestamp = stepInitial.formattedSignInTime,
),
),
)
Text(
modifier = Modifier
@@ -1,103 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.features.verifysession.impl.incoming.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.impl.R
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.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.Text
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SessionDetailsView(
deviceName: String?,
deviceId: DeviceId,
signInFormattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = ElementTheme.colors.borderDisabled,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp)
.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 = deviceName ?: deviceId.value,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
text = signInFormattedTimestamp,
modifier = Modifier.weight(2f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = deviceId.value,
modifier = Modifier.weight(5f),
spellText = true,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SessionDetailsViewPreview() = ElementPreview {
Column {
SessionDetailsView(
deviceName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
SessionDetailsView(
deviceName = null,
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}
}
@@ -18,10 +18,16 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
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 io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -41,6 +47,39 @@ class IncomingVerificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - the current user is built from the stored session`() = runTest {
createPresenter(
service = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = { },
resetLambda = { },
),
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
userAvatarData = "avatarData",
)
),
),
).test {
val step = awaitItem().step as IncomingVerificationState.Step.Initial
assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID))
val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial
assertThat(stepWithUser.currentUser).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
avatarThumbnail = "avatarData",
)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
@@ -59,6 +98,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false",
@@ -128,6 +168,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false",
@@ -193,6 +234,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false",
@@ -228,6 +270,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false",
@@ -315,10 +358,13 @@ internal fun TestScope.createPresenter(
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
sessionStore: SessionStore = InMemorySessionStore(),
) = IncomingVerificationPresenter(
verificationRequest = verificationRequest,
navigator = navigator,
sessionVerificationService = service,
sessionId = A_SESSION_ID,
sessionStore = sessionStore,
stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope,