Merge pull request #7823 from element-hq/feature/bma/multiAccountIncomingVerification

Multi account incoming device verification
This commit is contained in:
Benoit Marty
2026-10-02 08:10:19 +02:00
committed by GitHub
28 changed files with 581 additions and 166 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,