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

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState 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.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.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
@ -33,6 +34,7 @@ data class MessagesState(
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val userEventPermissions: UserEventPermissions, val userEventPermissions: UserEventPermissions,
val composerState: MessageComposerState, val composerState: MessageComposerState,
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState, 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.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState 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.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.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState 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.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@ -92,6 +94,7 @@ fun aMessagesState(
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal, mode = MessageComposerMode.Normal,
), ),
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState( timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()), timelineItems = aTimelineItemList(aTimelineItemTextContent()),
@ -117,6 +120,7 @@ fun aMessagesState(
heroes = persistentListOf(), heroes = persistentListOf(),
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
composerState = composerState, composerState = composerState,
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState, 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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView 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.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.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView 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.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId 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.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.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -425,13 +427,20 @@ private fun MessagesViewComposerBottomSheetContents(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
) )
} }
MessageComposerView( val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
state = state.composerState, it.identityState == IdentityState.VerificationViolation
voiceMessageState = state.voiceMessageComposerState, }
subcomposing = subcomposing, if (verificationViolation != null) {
enableVoiceMessages = state.enableVoiceMessages, DisabledComposerView(modifier = Modifier.fillMaxWidth())
modifier = Modifier.fillMaxWidth(), } else {
) MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)
}
} }
} else { } else {
CantSendMessageBanner() 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 import io.element.android.libraries.matrix.api.core.UserId
sealed interface IdentityChangeEvent { 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 package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope 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.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.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -44,12 +30,17 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState { override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) { val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange() observeRoomMemberIdentityStateChange(room)
} }
fun handleEvent(event: IdentityChangeEvent) { fun handleEvent(event: IdentityChangeEvent) {
when (event) { 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 { private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
encryptionService.pinUserIdentity(userId) encryptionService.pinUserIdentity(userId)
.onFailure { .onFailure {
Timber.e(it, "Failed to pin identity for user $userId") 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( roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange( aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"), identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityState = IdentityState.PinViolation, identityState = IdentityState.VerificationViolation,
), ),
), ),
), ),
@ -47,9 +47,10 @@ internal fun aRoomMemberIdentityStateChange(
internal fun anIdentityChangeState( internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(), roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
eventSink: (IdentityChangeEvent) -> Unit = {},
) = IdentityChangeState( ) = IdentityChangeState(
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
eventSink = {}, eventSink = eventSink,
) )
internal fun anIdentityRoomMember( internal fun anIdentityRoomMember(

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.crypto.identity package io.element.android.features.messages.impl.crypto.identity
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.appconfig.LearnMoreConfig 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.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -29,63 +32,90 @@ fun IdentityChangeStateView(
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Pick the first identity change to PinViolation // Pick the first identity change that is a violation
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull { val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
// For now only render PinViolation it.identityState.isAViolation()
it.identityState == IdentityState.PinViolation
} }
if (pinViolationIdentityChange != null) { when (identityChangeViolation?.identityState) {
ComposerAlertMolecule( 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, 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 @PreviewsDayNight
@Composable @Composable
internal fun IdentityChangeStateViewPreview( 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( return MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
isFullScreen = isFullScreen.value, 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, enableVoiceMessages = true,
subcomposing = false, subcomposing = false,
) )
DisabledComposerView()
} }
} }

View file

@ -143,7 +143,22 @@ class IdentityChangeStatePresenterTest {
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test { presenter.test {
val initialState = awaitItem() 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)) 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, text = content,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary, color = if (isCritical) {
ElementTheme.colors.textCriticalPrimary
} else {
ElementTheme.colors.textPrimary
},
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
) )
} }

View file

@ -66,6 +66,15 @@ interface EncryptionService {
* Remember this identity, ensuring it does not result in a pin violation. * Remember this identity, ensuring it does not result in a pin violation.
*/ */
suspend fun pinUserIdentity(userId: UserId): Result<Unit> 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, VerificationViolation,
} }
fun IdentityState.isAViolation() = this == IdentityState.PinViolation || this == IdentityState.VerificationViolation

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee7cdf903b25290a383c19ffa8f55759449469019f4b387366a0c538d59cc4b8 oid sha256:66343e5a2a84a1caf4d4606ec3985866be85ee4d4a0b02d8fd374364c561f411
size 69210 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 version https://git-lfs.github.com/spec/v1
oid sha256:409936daa72c7e70334f2ff5d62e84e41b5b79277fefb064b4a9183bd3925549 oid sha256:ceb6210817e79599a968157aebb7fd1d4e80ddbb8f3e57aabfb58d8f6066b9d4
size 16357 size 19455

View file

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

View file

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

View file

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