Merge pull request #4126 from element-hq/feature/valere/support_verification_violation_banner

feature(crypto): verification violation handling and block sending
This commit is contained in:
Benoit Marty 2025-02-18 16:07:29 +01:00 committed by GitHub
commit e128eca991
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 476 additions and 143 deletions

View file

@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
@ -78,6 +79,7 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -154,7 +156,9 @@ class MessagesPresenter @AssistedInject constructor(
var hasDismissedInviteDialog by rememberSaveable {
mutableStateOf(false)
}
val roomMemberIdentityStateChanges by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room)
}
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline.
@ -211,6 +215,7 @@ class MessagesPresenter @AssistedInject constructor(
roomAvatar = roomAvatar,
heroes = heroes,
composerState = composerState,
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
@ -33,6 +34,7 @@ data class MessagesState(
val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState,
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@ -41,6 +42,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@ -92,6 +94,7 @@ fun aMessagesState(
isFullScreen = false,
mode = MessageComposerMode.Normal,
),
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
@ -117,6 +120,7 @@ fun aMessagesState(
heroes = persistentListOf(),
userEventPermissions = userEventPermissions,
composerState = composerState,
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,

View file

@ -55,6 +55,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
@ -97,6 +98,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@ -425,13 +427,20 @@ private fun MessagesViewComposerBottomSheetContents(
onLinkClick = onLinkClick,
)
}
MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
}
if (verificationViolation != null) {
DisabledComposerView(modifier = Modifier.fillMaxWidth())
} else {
MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)
}
}
} else {
CantSendMessageBanner()

View file

@ -10,5 +10,6 @@ package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.matrix.api.core.UserId
sealed interface IdentityChangeEvent {
data class Submit(val userId: UserId) : IdentityChangeEvent
data class PinIdentity(val userId: UserId) : IdentityChangeEvent
data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent
}

View file

@ -8,30 +8,16 @@
package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -44,12 +30,17 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange()
observeRoomMemberIdentityStateChange(room)
}
fun handleEvent(event: IdentityChangeEvent) {
when (event) {
is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId)
is IdentityChangeEvent.WithdrawVerification -> {
coroutineScope.withdrawVerification(event.userId)
}
is IdentityChangeEvent.PinIdentity -> {
coroutineScope.pinUserIdentity(event.userId)
}
}
}
@ -59,56 +50,17 @@ class IdentityChangeStatePresenter @Inject constructor(
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}
.launchIn(this)
}
private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
encryptionService.pinUserIdentity(userId)
.onFailure {
Timber.e(it, "Failed to pin identity for user $userId")
}
}
private fun CoroutineScope.withdrawVerification(userId: UserId) = launch {
encryptionService.withdrawVerification(userId)
.onFailure {
Timber.e(it, "Failed to withdraw verification for user $userId")
}
}
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)

View file

@ -30,7 +30,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityState = IdentityState.PinViolation,
identityState = IdentityState.VerificationViolation,
),
),
),
@ -47,9 +47,10 @@ internal fun aRoomMemberIdentityStateChange(
internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
eventSink: (IdentityChangeEvent) -> Unit = {},
) = IdentityChangeState(
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
eventSink = {},
eventSink = eventSink,
)
internal fun anIdentityRoomMember(

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.crypto.identity
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -17,10 +18,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -29,63 +32,90 @@ fun IdentityChangeStateView(
onLinkClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
// Pick the first identity change to PinViolation
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull {
// For now only render PinViolation
it.identityState == IdentityState.PinViolation
// Pick the first identity change that is a violation
val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
it.identityState.isAViolation()
}
if (pinViolationIdentityChange != null) {
ComposerAlertMolecule(
when (identityChangeViolation?.identityState) {
IdentityState.PinViolation -> ViolationAlert(
identityChangeViolation = identityChangeViolation,
onLinkClick = onLinkClick,
textId = CommonStrings.crypto_identity_change_pin_violation_new,
isCritical = false,
submitTextId = CommonStrings.action_ok,
onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) },
modifier = modifier,
avatar = pinViolationIdentityChange.identityRoomMember.avatarData,
content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val displayName = pinViolationIdentityChange.identityRoomMember.displayNameOrDefault
val userIdStr = stringResource(
CommonStrings.crypto_identity_change_pin_violation_new_user_id,
pinViolationIdentityChange.identityRoomMember.userId,
)
val fullText = stringResource(
id = CommonStrings.crypto_identity_change_pin_violation_new,
displayName,
userIdStr,
learnMoreStr,
)
append(fullText)
val userIdStartIndex = fullText.indexOf(userIdStr)
addStyle(
style = SpanStyle(
fontWeight = FontWeight.Bold,
),
start = userIdStartIndex,
end = userIdStartIndex + userIdStr.length,
)
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
addLink(
url = LinkAnnotation.Url(
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
linkInteractionListener = {
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true)
}
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
},
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) },
isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation,
)
IdentityState.VerificationViolation -> ViolationAlert(
identityChangeViolation = identityChangeViolation,
onLinkClick = onLinkClick,
textId = CommonStrings.crypto_identity_change_verification_violation_new,
isCritical = true,
submitTextId = CommonStrings.crypto_identity_change_withdraw_verification_action,
onSubmitClick = { state.eventSink(IdentityChangeEvent.WithdrawVerification(identityChangeViolation.identityRoomMember.userId)) },
modifier = modifier,
)
else -> Unit
}
}
@Composable
private fun ViolationAlert(
identityChangeViolation: RoomMemberIdentityStateChange,
onLinkClick: (String, Boolean) -> Unit,
@StringRes textId: Int,
isCritical: Boolean,
@StringRes submitTextId: Int,
onSubmitClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ComposerAlertMolecule(
modifier = modifier,
avatar = identityChangeViolation.identityRoomMember.avatarData,
content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val displayName = identityChangeViolation.identityRoomMember.displayNameOrDefault
val userIdStr = stringResource(
CommonStrings.crypto_identity_change_pin_violation_new_user_id,
identityChangeViolation.identityRoomMember.userId,
)
val fullText = stringResource(textId, displayName, userIdStr, learnMoreStr)
append(fullText)
val userIdStartIndex = fullText.indexOf(userIdStr)
addStyle(
style = SpanStyle(
fontWeight = FontWeight.Bold,
),
start = userIdStartIndex,
end = userIdStartIndex + userIdStr.length,
)
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = ElementTheme.colors.textPrimary
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
addLink(
url = LinkAnnotation.Url(
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
linkInteractionListener = {
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true)
}
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
},
submitText = stringResource(submitTextId),
onSubmitClick = onSubmitClick,
isCritical = isCritical,
)
}
@PreviewsDayNight
@Composable
internal fun IdentityChangeStateViewPreview(

View file

@ -0,0 +1,101 @@
/*
* Copyright 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.messages.impl.messagecomposer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.R
@Composable
internal fun DisabledComposerView(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(3.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
modifier = Modifier
.size(48.dp),
enabled = false,
onClick = {},
) {
Icon(
modifier = Modifier.size(30.dp),
resourceId = CommonDrawables.ic_plus_composer,
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
tint = ElementTheme.colors.iconDisabled,
)
}
val bgColor = ElementTheme.colors.bgCanvasDisabled
val borderColor = ElementTheme.colors.borderDisabled
Box(
modifier = Modifier
.clip(RoundedCornerShape(21.dp))
.border(0.5.dp, borderColor, RoundedCornerShape(21.dp))
.background(color = bgColor)
.size(42.dp)
.requiredHeightIn(min = 42.dp)
.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
modifier = Modifier
.padding(start = 2.dp)
.size(48.dp),
enabled = false,
onClick = {},
) {
Icon(
modifier = Modifier.size(30.dp),
imageVector = CompoundIcons.SendSolid(),
contentDescription = "",
tint = ElementTheme.colors.iconQuaternary
)
}
}
}
@PreviewsDayNight
@Composable
internal fun DisabledComposerViewPreview() = ElementPreview {
Column {
DisabledComposerView(
modifier = Modifier.height(IntrinsicSize.Min),
)
}
}

View file

@ -389,6 +389,7 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
}
return MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,

View file

@ -0,0 +1,74 @@
/*
* Copyright 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.messages.impl.messagecomposer
import androidx.compose.runtime.ProduceStateScope
import io.element.android.features.messages.impl.crypto.identity.IdentityRoomMember
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@OptIn(ExperimentalCoroutinesApi::class)
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}
.launchIn(this)
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)

View file

@ -139,6 +139,7 @@ internal fun MessageComposerViewPreview(
enableVoiceMessages = true,
subcomposing = false,
)
DisabledComposerView()
}
}

View file

@ -143,7 +143,22 @@ class IdentityChangeStatePresenterTest {
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(IdentityChangeEvent.Submit(A_USER_ID))
initialState.eventSink(IdentityChangeEvent.PinIdentity(A_USER_ID))
lambda.assertions().isCalledOnce().with(value(A_USER_ID))
}
}
@Test
fun `present - when the user withdraws the identity, the presenter invokes the encryption service api`() =
runTest {
val lambda = lambdaRecorder<UserId, Result<Unit>> { Result.success(Unit) }
val encryptionService = FakeEncryptionService(
withdrawVerificationResult = lambda,
)
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(IdentityChangeEvent.WithdrawVerification(A_USER_ID))
lambda.assertions().isCalledOnce().with(value(A_USER_ID))
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright 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.messages.impl.crypto.identity
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class IdentityChangeStateViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `show and resolve pin violation`() {
val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
rule.setIdentityChangeStateView(
state = anIdentityChangeState(
listOf(
RoomMemberIdentityStateChange(
identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()),
identityState = IdentityState.PinViolation
)
),
eventsRecorder
),
)
rule.onNodeWithText("identity appears to have changed", substring = true).assertExists("should display pin violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.action_ok)
eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost")))
}
@Test
fun `show and resolve verification violation`() {
val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
rule.setIdentityChangeStateView(
state = anIdentityChangeState(
listOf(
RoomMemberIdentityStateChange(
identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()),
identityState = IdentityState.VerificationViolation
)
),
eventsRecorder
),
)
rule.onNodeWithText("verified identity has changed", substring = true).assertExists("should display verification violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action)
eventsRecorder.assertSingle(IdentityChangeEvent.WithdrawVerification(UserId("@alice:localhost")))
}
@Test
fun `Should not show any banner if no violations`() {
rule.setIdentityChangeStateView(
state = anIdentityChangeState(
listOf(
RoomMemberIdentityStateChange(
identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()),
identityState = IdentityState.Verified
),
RoomMemberIdentityStateChange(
identityRoomMember = IdentityRoomMember(UserId("@bob:localhost"), "Bob", anAvatarData()),
identityState = IdentityState.Pinned
)
),
),
)
rule.onNodeWithText("identity appears to have changed", substring = true).assertDoesNotExist()
rule.onNodeWithText("verified identity has changed", substring = true).assertDoesNotExist()
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView(
state: IdentityChangeState,
) {
setContent {
IdentityChangeStateView(
state = state,
onLinkClick = { _, _ -> },
)
}
}
}

View file

@ -78,7 +78,11 @@ fun ComposerAlertMolecule(
text = content,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
color = if (isCritical) {
ElementTheme.colors.textCriticalPrimary
} else {
ElementTheme.colors.textPrimary
},
textAlign = TextAlign.Start,
)
}

View file

@ -66,6 +66,15 @@ interface EncryptionService {
* Remember this identity, ensuring it does not result in a pin violation.
*/
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
/**
* Withdraw the verification for that user (also pin the identity).
*
* Useful when a user that was verified is not anymore, but it is not
* possible to re-verify immediately. This allows to restore communication by reverting the
* user trust from verified to TOFU verified.
*/
suspend fun withdrawVerification(userId: UserId): Result<Unit>
}
/**

View file

@ -32,3 +32,5 @@ enum class IdentityState {
*/
VerificationViolation,
}
fun IdentityState.isAViolation() = this == IdentityState.PinViolation || this == IdentityState.VerificationViolation

View file

@ -209,6 +209,10 @@ internal class RustEncryptionService(
getUserIdentity(userId).pin()
}
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).withdrawVerification()
}
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
return service.userIdentity(
userId = userId.value,

View file

@ -24,6 +24,7 @@ class FakeEncryptionService(
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@ -124,6 +125,10 @@ class FakeEncryptionService(
return pinUserIdentityResult(userId)
}
override suspend fun withdrawVerification(userId: UserId): Result<Unit> {
return withdrawVerificationResult(userId)
}
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = simulateLongTask {
isUserVerifiedResult(userId)
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ffda5a15b4c2a56009940901b0f5b102536efbc77a540c9814c4e644618509e
size 25186
oid sha256:0bceb89945fbdc84bfd2778d5061b61b5602a147c8c4325f1893b9a2e5dd411e
size 27588

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9370c42c42cbb9878244c4a1318d7e59725afc90ab1bca818a2a2619a41a6479
size 27931
oid sha256:b098eebc72bc5388453bc7a8ba46e62fb64b994b5240dda4bf1f59161362e97a
size 29408

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab8c08410091863015fdf8e9cfeaf4259f470b500d7901d3dffff83483fb53a3
size 65168
oid sha256:62baf6570e7b118a943efd1aa7482d865a3984646cc41afa28a8dccd6246b580
size 68313

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee7cdf903b25290a383c19ffa8f55759449469019f4b387366a0c538d59cc4b8
size 69210
oid sha256:66343e5a2a84a1caf4d4606ec3985866be85ee4d4a0b02d8fd374364c561f411
size 70575

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d866ee561b4109bf1bb4d1cc3b34d32ad0c67ae51f7599088898acd4310f961
size 6725

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3fcb7d90743e35ebf03f09129e02f4df7d0b3b883c451fb45f57f70669b5d4c5
size 6382

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:409936daa72c7e70334f2ff5d62e84e41b5b79277fefb064b4a9183bd3925549
size 16357
oid sha256:ceb6210817e79599a968157aebb7fd1d4e80ddbb8f3e57aabfb58d8f6066b9d4
size 19455

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99c74f986295bd66f52346723a562a8d87b4eca9bda021220e8e916fecc86784
size 15106
oid sha256:6394543bdd107f70a09bdcb164163984112a25f90eed93850cb525572dd53045
size 17888

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:267bd9c09da1732222fc833e302b06c2107a2964524e8f98f5fdd7d2884543a5
size 20808
oid sha256:ff4fbcc142dac5a501eabb228009663eed497a2d30d8e29e0fe68b462a82997d
size 20369

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35499aea0d8741002ee546553ac0ec8fee64abeb93db8913e585804ed28539fc
size 23451
oid sha256:570d33be676b5a603f13c1beb364d41e8c19a6d1c501c3082cc197cd26344d4e
size 22546