feature(crypto): verification violation handling and block sending

This commit is contained in:
Valere 2025-01-08 10:38:55 +01:00
parent ce1c01e626
commit 52c57d4d8e
13 changed files with 224 additions and 14 deletions

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 PinViolation(val userId: UserId) : IdentityChangeEvent
data class VerificationViolation(val userId: UserId) : IdentityChangeEvent
}

View file

@ -49,7 +49,12 @@ class IdentityChangeStatePresenter @Inject constructor(
fun handleEvent(event: IdentityChangeEvent) {
when (event) {
is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId)
is IdentityChangeEvent.VerificationViolation -> {
coroutineScope.withdrawVerificationRequirement(event.userId)
}
is IdentityChangeEvent.PinViolation -> {
coroutineScope.pinUserIdentity(event.userId)
}
}
}
@ -94,15 +99,22 @@ class IdentityChangeStatePresenter @Inject constructor(
Timber.e(it, "Failed to pin identity for user $userId")
}
}
private fun CoroutineScope.withdrawVerificationRequirement(userId: UserId) = launch {
encryptionService.withdrawVerificationRequirement(userId)
.onFailure {
Timber.e(it, "Failed to withdraw verification for user $userId")
}
}
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(

View file

@ -30,7 +30,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityState = IdentityState.PinViolation,
identityState = IdentityState.VerificationViolation,
),
),
),

View file

@ -17,6 +17,7 @@ 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
@ -32,7 +33,8 @@ fun IdentityChangeStateView(
// Pick the first identity change to PinViolation
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull {
// For now only render PinViolation
it.identityState == IdentityState.PinViolation
it.identityState == IdentityState.PinViolation ||
it.identityState == IdentityState.VerificationViolation
}
if (pinViolationIdentityChange != null) {
ComposerAlertMolecule(
@ -45,12 +47,22 @@ fun IdentityChangeStateView(
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,
)
val fullText = if (pinViolationIdentityChange.identityState == IdentityState.PinViolation) {
stringResource(
id = CommonStrings.crypto_identity_change_pin_violation_new,
displayName,
userIdStr,
learnMoreStr,
)
} else {
stringResource(
id = CommonStrings.crypto_identity_change_verification_violation_new,
displayName,
userIdStr,
learnMoreStr,
)
}
append(fullText)
val userIdStartIndex = fullText.indexOf(userIdStr)
addStyle(
@ -65,6 +77,7 @@ fun IdentityChangeStateView(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = ElementTheme.colors.textPrimary
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
@ -80,7 +93,18 @@ fun IdentityChangeStateView(
end = learnMoreStartIndex + learnMoreStr.length,
)
},
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) },
submitText = if (pinViolationIdentityChange.identityState == IdentityState.VerificationViolation) {
stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action)
} else {
stringResource(CommonStrings.action_ok)
},
onSubmitClick = {
if (pinViolationIdentityChange.identityState == IdentityState.VerificationViolation) {
state.eventSink(IdentityChangeEvent.VerificationViolation(pinViolationIdentityChange.identityRoomMember.userId))
} else {
state.eventSink(IdentityChangeEvent.PinViolation(pinViolationIdentityChange.identityRoomMember.userId))
}
},
isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation,
)
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.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.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(4.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),
)
IconButton(
modifier = Modifier
.size(48.dp),
enabled = false,
onClick = {},
) {
Icon(
modifier = Modifier.size(30.dp),
imageVector = CompoundIcons.SendSolid(),
contentDescription = "",
tint = ElementTheme.colors.iconQuaternary
)
}
}
}
@PreviewsDayNight
@Composable
internal fun DisabledMessageComposerViewVoicePreview() = ElementPreview {
Column {
DisabledComposerView(
modifier = Modifier.height(IntrinsicSize.Min),
)
}
}

View file

@ -14,10 +14,12 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
@ -33,6 +35,9 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.createDefaultRoomMemberForIdentityChange
import io.element.android.features.messages.impl.crypto.identity.toIdentityRoomMember
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
@ -52,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
@ -76,18 +82,24 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ -389,6 +401,11 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
}
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange()
}
return MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
@ -398,6 +415,7 @@ class MessageComposerPresenter @AssistedInject constructor(
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
suggestions = suggestions.toPersistentList(),
roomMemberIdentityStateChanges = roomMemberIdentityStateChange,
resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
)
@ -705,4 +723,33 @@ class MessageComposerPresenter @AssistedInject 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)
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -24,6 +25,7 @@ data class MessageComposerState(
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val suggestions: ImmutableList<ResolvedSuggestion>,
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -32,6 +33,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
identityStates: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
eventSink: (MessageComposerEvents) -> Unit = {},
) = MessageComposerState(
textEditorState = textEditorState,
@ -42,6 +44,7 @@ fun aMessageComposerState(
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
suggestions = suggestions,
roomMemberIdentityStateChanges = identityStates,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = eventSink,
)

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
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.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
@ -37,6 +38,14 @@ internal fun MessageComposerView(
enableVoiceMessages: Boolean,
modifier: Modifier = Modifier,
) {
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
}
if (verificationViolation != null) {
DisabledComposerView(modifier = modifier)
return
}
val view = LocalView.current
fun sendMessage() {
state.eventSink(MessageComposerEvents.SendMessage)
@ -139,6 +148,7 @@ internal fun MessageComposerViewPreview(
enableVoiceMessages = true,
subcomposing = false,
)
DisabledComposerView()
}
}

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,7 @@ interface EncryptionService {
* Remember this identity, ensuring it does not result in a pin violation.
*/
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
suspend fun withdrawVerificationRequirement(userId: UserId): Result<Unit>
}
/**

View file

@ -209,6 +209,10 @@ internal class RustEncryptionService(
getUserIdentity(userId).pin()
}
override suspend fun withdrawVerificationRequirement(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 withdrawVerificationRequirement(userId: UserId): Result<Unit> {
return withdrawVerificationResult(userId)
}
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = simulateLongTask {
isUserVerifiedResult(userId)
}