Implement user verification (#4294)

* Add support for starting verification of a user

* Add support for replying to incoming user verification requests

* Add reset recovery key button and previews to `ChooseSelfVerificationModeView`

* Add 'Profile' item in room details screen

* Update screenshots

* Remove `showDeviceVerifiedScreen` parameter from `NavTarget.UseAnotherDevice`

* Allow exiting the FTUE flow, which will close the app. The previous state will be restored when the app is reopened.

* When outgoing verification fails, move to the `Canceled` state. Then, when resetting the state machine state also reset the verification service.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-10 11:20:17 +01:00 committed by GitHub
parent 6f54de58f3
commit b91933625c
145 changed files with 1662 additions and 830 deletions

View file

@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -21,6 +22,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@ -35,11 +37,13 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.designsystem.utils.OpenUrlInTabView
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -47,6 +51,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
@ -65,6 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -118,6 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object SecurityAndPrivacy : NavTarget
@Parcelize
data class VerifyUser(val userId: UserId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -168,6 +177,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecurityAndPrivacy)
}
override fun openDmUserProfile(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onJoinCall() {
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
@ -224,6 +237,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onStartCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
}
override fun onVerifyUser(userId: UserId) {
backstack.push(NavTarget.VerifyUser(userId))
}
}
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
createNode<RoomMemberDetailsNode>(buildContext, plugins)
@ -301,11 +318,37 @@ class RoomDetailsFlowNode @AssistedInject constructor(
NavTarget.SecurityAndPrivacy -> {
createNode<SecurityAndPrivacyFlowNode>(buildContext)
}
is NavTarget.VerifyUser -> {
val params = VerifySessionEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
)
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(object : VerifySessionEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
override fun onBack() {
backstack.pop()
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
}
})
.build()
}
}
}
private val learnMoreUrl = mutableStateOf<String?>(null)
@Composable
override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier)
OpenUrlInTabView(learnMoreUrl)
}
}

View file

@ -23,6 +23,7 @@ import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@ -50,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openPinnedMessagesList()
fun openKnockRequestsList()
fun openSecurityAndPrivacy()
fun openDmUserProfile(userId: UserId)
fun onJoinCall()
}
@ -126,6 +128,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openSecurityAndPrivacy() }
}
private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@ -158,7 +164,8 @@ class RoomDetailsNode @AssistedInject constructor(
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages,
onKnockRequestsClick = ::openKnockRequestsLists,
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
onProfileClick = ::onProfileClick,
)
}
}

View file

@ -70,6 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName
@ -101,6 +102,7 @@ fun RoomDetailsView(
onPinnedMessagesClick: () -> Unit,
onKnockRequestsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit,
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -179,11 +181,16 @@ fun RoomDetailsView(
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.canShowSecurityAndPrivacy) {
SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick
)
}
state.roomMemberDetailsState?.let { dmMemberDetails ->
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
}
}
if (state.roomType is RoomDetailsType.Room) {
@ -546,6 +553,17 @@ private fun FavoriteItem(
)
}
@Composable
private fun ProfileItem(
onClick: () -> Unit,
) {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) },
onClick = onClick,
)
}
@Composable
private fun MembersItem(
memberCount: Long,
@ -655,5 +673,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onPinnedMessagesClick = {},
onKnockRequestsClick = {},
onSecurityAndPrivacyClick = {},
onProfileClick = {},
)
}

View file

@ -79,6 +79,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
onOpenDm = ::onStartDM,
onStartCall = ::onStartCall,
openAvatarPreview = callback::openAvatarPreview,
onVerifyClick = callback::onVerifyUser,
)
}
}

View file

@ -15,7 +15,10 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
@ -294,6 +297,21 @@ class RoomDetailsViewTest {
rule.clickOn(R.string.screen_room_details_requests_to_join_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on profile invokes the expected callback`() {
ensureCalledOnceWithParam(A_USER_ID) { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
roomMemberDetailsState = aUserProfileState(userId = A_USER_ID),
),
onProfileClick = callback,
)
rule.clickOn(R.string.screen_room_details_profile_row_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
@ -314,6 +332,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomDetailsView(
@ -332,6 +351,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onPinnedMessagesClick = onPinnedMessagesClick,
onKnockRequestsClick = onKnockRequestsClick,
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
onProfileClick = onProfileClick,
)
}
}