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 bc11f29df3..149789d97d 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,6 +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 PinViolation(val userId: UserId) : IdentityChangeEvent - data class VerificationViolation(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 2b41091168..745343d4e2 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,15 +34,15 @@ 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.VerificationViolation -> { - coroutineScope.withdrawVerificationRequirement(event.userId) + is IdentityChangeEvent.WithdrawVerification -> { + coroutineScope.withdrawVerification(event.userId) } - is IdentityChangeEvent.PinViolation -> { + is IdentityChangeEvent.PinIdentity -> { coroutineScope.pinUserIdentity(event.userId) } } @@ -64,35 +54,6 @@ 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 { @@ -100,8 +61,8 @@ class IdentityChangeStatePresenter @Inject constructor( } } - private fun CoroutineScope.withdrawVerificationRequirement(userId: UserId) = launch { - encryptionService.withdrawVerificationRequirement(userId) + private fun CoroutineScope.withdrawVerification(userId: UserId) = launch { + encryptionService.withdrawVerification(userId) .onFailure { Timber.e(it, "Failed to withdraw verification for user $userId") } 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 600f470926..31c017d22a 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 @@ -30,25 +30,24 @@ 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 + // 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, + maybeIdentityChangeViolation.identityRoomMember.userId, ) - val fullText = if (pinViolationIdentityChange.identityState == IdentityState.PinViolation) { + val fullText = if (maybeIdentityChangeViolation.identityState == IdentityState.PinViolation) { stringResource( id = CommonStrings.crypto_identity_change_pin_violation_new, displayName, @@ -93,19 +92,19 @@ fun IdentityChangeStateView( end = learnMoreStartIndex + learnMoreStr.length, ) }, - submitText = if (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 (pinViolationIdentityChange.identityState == IdentityState.VerificationViolation) { - state.eventSink(IdentityChangeEvent.VerificationViolation(pinViolationIdentityChange.identityRoomMember.userId)) + if (maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation) { + state.eventSink(IdentityChangeEvent.WithdrawVerification(maybeIdentityChangeViolation.identityRoomMember.userId)) } else { - state.eventSink(IdentityChangeEvent.PinViolation(pinViolationIdentityChange.identityRoomMember.userId)) + state.eventSink(IdentityChangeEvent.PinIdentity(maybeIdentityChangeViolation.identityRoomMember.userId)) } }, - isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation, + isCritical = maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation, ) } } 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 7945cc6bb1..93ad8f59ba 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 @@ -403,7 +403,7 @@ class MessageComposerPresenter @AssistedInject constructor( } val roomMemberIdentityStateChange by produceState(persistentListOf()) { - observeRoomMemberIdentityStateChange() + observeRoomMemberIdentityStateChange(room) } return MessageComposerState( 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 0000000000..f1ec4f6156 --- /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/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 04153cd827..934bff7f49 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,7 @@ class IdentityChangeStatePresenterTest { val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) presenter.test { val initialState = awaitItem() - initialState.eventSink(IdentityChangeEvent.PinViolation(A_USER_ID)) + initialState.eventSink(IdentityChangeEvent.PinIdentity(A_USER_ID)) lambda.assertions().isCalledOnce().with(value(A_USER_ID)) } } @@ -158,7 +158,7 @@ class IdentityChangeStatePresenterTest { val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) presenter.test { val initialState = awaitItem() - initialState.eventSink(IdentityChangeEvent.VerificationViolation(A_USER_ID)) + 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 index 3642fcd3c9..67ee5689e1 100644 --- 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 @@ -47,7 +47,7 @@ class IdentityChangeStateViewTest { rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.clickOn(res = CommonStrings.action_ok) - eventsRecorder.assertSingle(IdentityChangeEvent.PinViolation(UserId("@alice:localhost"))) + eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost"))) } @Test @@ -70,7 +70,7 @@ class IdentityChangeStateViewTest { rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action) - eventsRecorder.assertSingle(IdentityChangeEvent.VerificationViolation(UserId("@alice:localhost"))) + eventsRecorder.assertSingle(IdentityChangeEvent.WithdrawVerification(UserId("@alice:localhost"))) } @Test 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 5b2703bf25..a1a4eb9b1c 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,7 +66,15 @@ interface EncryptionService { * Remember this identity, ensuring it does not result in a pin violation. */ suspend fun pinUserIdentity(userId: UserId): Result - suspend fun withdrawVerificationRequirement(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 3c3228ceb9..2730d93c87 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,7 +209,7 @@ internal class RustEncryptionService( getUserIdentity(userId).pin() } - override suspend fun withdrawVerificationRequirement(userId: UserId): Result = runCatching { + override suspend fun withdrawVerification(userId: UserId): Result = runCatching { getUserIdentity(userId).withdrawVerification() } 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 0be0434d38..7d56ab3d7e 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 @@ -125,7 +125,7 @@ class FakeEncryptionService( return pinUserIdentityResult(userId) } - override suspend fun withdrawVerificationRequirement(userId: UserId): Result { + override suspend fun withdrawVerification(userId: UserId): Result { return withdrawVerificationResult(userId) }