diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt index e74004c1d02..149789d97d4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt @@ -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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt index adc39fbc746..745343d4e2f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -8,10 +8,10 @@ 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 @@ -19,19 +19,9 @@ 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 +34,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,50 +54,28 @@ class IdentityChangeStatePresenter @Inject constructor( ) } - @OptIn(ExperimentalCoroutinesApi::class) - private fun ProduceStateScope>.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( +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( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt index 5ec38588091..50ca0d7e3ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt @@ -30,7 +30,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider = emptyList(), + eventSink: (IdentityChangeEvent) -> Unit = {} ) = IdentityChangeState( roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), - eventSink = {}, + eventSink, ) internal fun anIdentityRoomMember( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt index a7b78ca84b0..31c017d22a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -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 @@ -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 } - 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( @@ -65,6 +76,7 @@ fun IdentityChangeStateView( style = SpanStyle( textDecoration = TextDecoration.Underline, fontWeight = FontWeight.Bold, + color = ElementTheme.colors.textPrimary ), start = learnMoreStartIndex, end = learnMoreStartIndex + learnMoreStr.length, @@ -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, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt new file mode 100644 index 00000000000..19e1758be8e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt @@ -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), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 94aecec7781..16b908a7b53 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 @@ -389,6 +390,11 @@ class MessageComposerPresenter @AssistedInject constructor( } } } + + val roomMemberIdentityStateChange by produceState(persistentListOf()) { + observeRoomMemberIdentityStateChange(room) + } + return MessageComposerState( textEditorState = textEditorState, isFullScreen = isFullScreen.value, @@ -398,6 +404,7 @@ class MessageComposerPresenter @AssistedInject constructor( canShareLocation = canShareLocation.value, canCreatePoll = canCreatePoll.value, suggestions = suggestions.toPersistentList(), + roomMemberIdentityStateChanges = roomMemberIdentityStateChange, resolveMentionDisplay = resolveMentionDisplay, eventSink = { handleEvents(it) }, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index d86ba8d98c4..4bb0f7f6fd6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -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, + val roomMemberIdentityStateChanges: ImmutableList, val resolveMentionDisplay: (String, String) -> TextDisplay, val eventSink: (MessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index c0ea895618e..4e0a6df5c9b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -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 = persistentListOf(), + identityStates: ImmutableList = 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, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt new file mode 100644 index 00000000000..f1ec4f61565 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerUtils.kt @@ -0,0 +1,53 @@ +/* + * 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.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.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.roomMembers +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>.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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 86e30a22c04..09289305b25 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -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() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt index 115e14c82c2..934bff7f49b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt @@ -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 withdraw the identity, the presenter invokes the encryption service api`() = + runTest { + val lambda = lambdaRecorder> { 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)) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt new file mode 100644 index 00000000000..ca07cc93cdf --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt @@ -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() + + @Test + fun `show and resolve pin violation`() { + val eventsRecorder = EventsRecorder() + 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() + 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 AndroidComposeTestRule.setIdentityChangeStateView( + state: IdentityChangeState, + ) { + setContent { + IdentityChangeStateView( + state = state, + onLinkClick = { _, _ -> }, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt index 30944ead690..b85e31c5140 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt @@ -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, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index bc009d3ee89..a1a4eb9b1cc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -66,6 +66,15 @@ interface EncryptionService { * Remember this identity, ensuring it does not result in a pin violation. */ suspend fun pinUserIdentity(userId: UserId): Result + + /** + * 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 } /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index 3e0e6571d4c..2730d93c878 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -209,6 +209,10 @@ internal class RustEncryptionService( getUserIdentity(userId).pin() } + override suspend fun withdrawVerification(userId: UserId): Result = runCatching { + getUserIdentity(userId).withdrawVerification() + } + private suspend fun getUserIdentity(userId: UserId): UserIdentity { return service.userIdentity( userId = userId.value, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index ee126e2f92d..7d56ab3d7ea 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -24,6 +24,7 @@ class FakeEncryptionService( var startIdentityResetLambda: () -> Result = { lambdaError() }, private val pinUserIdentityResult: (UserId) -> Result = { lambdaError() }, private val isUserVerifiedResult: (UserId) -> Result = { lambdaError() }, + private val withdrawVerificationResult: (UserId) -> Result = { lambdaError() }, ) : EncryptionService { private var disableRecoveryFailure: Exception? = null override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) @@ -124,6 +125,10 @@ class FakeEncryptionService( return pinUserIdentityResult(userId) } + override suspend fun withdrawVerification(userId: UserId): Result { + return withdrawVerificationResult(userId) + } + override suspend fun isUserVerified(userId: UserId): Result = simulateLongTask { isUserVerifiedResult(userId) } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en.png index 02192807520..d2e968638ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ffda5a15b4c2a56009940901b0f5b102536efbc77a540c9814c4e644618509e -size 25186 +oid sha256:0bceb89945fbdc84bfd2778d5061b61b5602a147c8c4325f1893b9a2e5dd411e +size 27588 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en.png index 234e7f99d2a..aa095fb225c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9370c42c42cbb9878244c4a1318d7e59725afc90ab1bca818a2a2619a41a6479 -size 27931 +oid sha256:b098eebc72bc5388453bc7a8ba46e62fb64b994b5240dda4bf1f59161362e97a +size 29408 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png index 73b3cec7113..857344e8d9d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab8c08410091863015fdf8e9cfeaf4259f470b500d7901d3dffff83483fb53a3 -size 65168 +oid sha256:62baf6570e7b118a943efd1aa7482d865a3984646cc41afa28a8dccd6246b580 +size 68313 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png index 6c06c9b772f..d87569a76c7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee7cdf903b25290a383c19ffa8f55759449469019f4b387366a0c538d59cc4b8 -size 69210 +oid sha256:66343e5a2a84a1caf4d4606ec3985866be85ee4d4a0b02d8fd374364c561f411 +size 70575 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Day_0_en.png new file mode 100644 index 00000000000..205952091dd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d866ee561b4109bf1bb4d1cc3b34d32ad0c67ae51f7599088898acd4310f961 +size 6725 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Night_0_en.png new file mode 100644 index 00000000000..a5be35d80b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_DisabledComposerView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fcb7d90743e35ebf03f09129e02f4df7d0b3b883c451fb45f57f70669b5d4c5 +size 6382 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Day_0_en.png index 37713151e1a..8bf1f125bc5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:409936daa72c7e70334f2ff5d62e84e41b5b79277fefb064b4a9183bd3925549 -size 16357 +oid sha256:ceb6210817e79599a968157aebb7fd1d4e80ddbb8f3e57aabfb58d8f6066b9d4 +size 19455 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Night_0_en.png index dc1fac59118..7743b1f55aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_MessageComposerView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99c74f986295bd66f52346723a562a8d87b4eca9bda021220e8e916fecc86784 -size 15106 +oid sha256:6394543bdd107f70a09bdcb164163984112a25f90eed93850cb525572dd53045 +size 17888 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png index efa4a70bf38..157f3e59e23 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:267bd9c09da1732222fc833e302b06c2107a2964524e8f98f5fdd7d2884543a5 -size 20808 +oid sha256:ff4fbcc142dac5a501eabb228009663eed497a2d30d8e29e0fe68b462a82997d +size 20369 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png index 311ce5e56b9..5338f9a2de3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35499aea0d8741002ee546553ac0ec8fee64abeb93db8913e585804ed28539fc -size 23451 +oid sha256:570d33be676b5a603f13c1beb364d41e8c19a6d1c501c3082cc197cd26344d4e +size 22546