Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(crypto): verification violation handling and block sending #4126

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,20 @@
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
Expand All @@ -44,12 +34,17 @@
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)
}
}
}

Expand All @@ -59,50 +54,28 @@
)
}

@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")
}

Check warning on line 68 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt#L67-L68

Added lines #L67 - L68 were not covered by tests
}
}

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityState = IdentityState.PinViolation,
identityState = IdentityState.VerificationViolation,
),
),
),
Expand All @@ -47,9 +47,10 @@ internal fun aRoomMemberIdentityStateChange(

internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
eventSink: (IdentityChangeEvent) -> Unit = {}
) = IdentityChangeState(
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
eventSink = {},
eventSink,
)

internal fun anIdentityRoomMember(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,28 +30,38 @@ 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 in Pin or Verification violation
val maybeIdentityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.PinViolation ||
it.identityState == IdentityState.VerificationViolation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the 2 comments above please? Looks like there are not true anymore :)

Also probably pinViolationIdentityChange could be rename to something more generic like firstRoomMemberIdentityStateChange.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

}
if (pinViolationIdentityChange != null) {
if (maybeIdentityChangeViolation != null) {
ComposerAlertMolecule(
modifier = modifier,
avatar = pinViolationIdentityChange.identityRoomMember.avatarData,
avatar = maybeIdentityChangeViolation.identityRoomMember.avatarData,
content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val displayName = pinViolationIdentityChange.identityRoomMember.displayNameOrDefault
val displayName = maybeIdentityChangeViolation.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,
maybeIdentityChangeViolation.identityRoomMember.userId,
)

val fullText = if (maybeIdentityChangeViolation.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(
Expand All @@ -65,6 +76,7 @@ fun IdentityChangeStateView(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = ElementTheme.colors.textPrimary
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
Expand All @@ -80,8 +92,19 @@ fun IdentityChangeStateView(
end = learnMoreStartIndex + learnMoreStr.length,
)
},
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) },
isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation,
submitText = if (maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation) {
stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action)
} else {
stringResource(CommonStrings.action_ok)
},
onSubmitClick = {
if (maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation) {
state.eventSink(IdentityChangeEvent.WithdrawVerification(maybeIdentityChangeViolation.identityRoomMember.userId))
} else {
state.eventSink(IdentityChangeEvent.PinIdentity(maybeIdentityChangeViolation.identityRoomMember.userId))
}
},
isCritical = maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the aim of this Composable, but, as @jmartinesp mentionned, we take the risk that if a change in the real composer UI is done, this component is not updated.

I have seen that you have added screenshot test in the same view than the real composer, so this may mitigate this risk, thanks.

So it's OK for me!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok. not ideal but enough for now. We need anyhow a way to have the composer disabled, and properly support transition to disabled in the middle of editing/recording ..

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),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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
Expand Down Expand Up @@ -389,6 +390,11 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
}

val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room)
}

return MessageComposerState(
textEditorState = textEditorState,
isFullScreen = isFullScreen.value,
Expand All @@ -398,6 +404,7 @@ class MessageComposerPresenter @AssistedInject constructor(
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
suggestions = suggestions.toPersistentList(),
roomMemberIdentityStateChanges = roomMemberIdentityStateChange,
resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Loading
Loading