code review: renaming, comments, extract common code

This commit is contained in:
Valere 2025-01-24 12:00:16 +01:00
parent 7cfba520a5
commit fe235de273
10 changed files with 90 additions and 69 deletions

View file

@ -10,6 +10,6 @@ package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
sealed interface IdentityChangeEvent { sealed interface IdentityChangeEvent {
data class PinViolation(val userId: UserId) : IdentityChangeEvent data class PinIdentity(val userId: UserId) : IdentityChangeEvent
data class VerificationViolation(val userId: UserId) : IdentityChangeEvent data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent
} }

View file

@ -8,10 +8,10 @@
package io.element.android.features.messages.impl.crypto.identity package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -44,15 +34,15 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState { override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) { val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange() observeRoomMemberIdentityStateChange(room)
} }
fun handleEvent(event: IdentityChangeEvent) { fun handleEvent(event: IdentityChangeEvent) {
when (event) { when (event) {
is IdentityChangeEvent.VerificationViolation -> { is IdentityChangeEvent.WithdrawVerification -> {
coroutineScope.withdrawVerificationRequirement(event.userId) coroutineScope.withdrawVerification(event.userId)
} }
is IdentityChangeEvent.PinViolation -> { is IdentityChangeEvent.PinIdentity -> {
coroutineScope.pinUserIdentity(event.userId) coroutineScope.pinUserIdentity(event.userId)
} }
} }
@ -64,35 +54,6 @@ class IdentityChangeStatePresenter @Inject constructor(
) )
} }
@OptIn(ExperimentalCoroutinesApi::class)
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}
.launchIn(this)
}
private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch { private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
encryptionService.pinUserIdentity(userId) encryptionService.pinUserIdentity(userId)
.onFailure { .onFailure {
@ -100,8 +61,8 @@ class IdentityChangeStatePresenter @Inject constructor(
} }
} }
private fun CoroutineScope.withdrawVerificationRequirement(userId: UserId) = launch { private fun CoroutineScope.withdrawVerification(userId: UserId) = launch {
encryptionService.withdrawVerificationRequirement(userId) encryptionService.withdrawVerification(userId)
.onFailure { .onFailure {
Timber.e(it, "Failed to withdraw verification for user $userId") Timber.e(it, "Failed to withdraw verification for user $userId")
} }

View file

@ -30,25 +30,24 @@ fun IdentityChangeStateView(
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Pick the first identity change to PinViolation // Pick the first identity change that is in Pin or Verification violation
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull { val maybeIdentityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
// For now only render PinViolation
it.identityState == IdentityState.PinViolation || it.identityState == IdentityState.PinViolation ||
it.identityState == IdentityState.VerificationViolation it.identityState == IdentityState.VerificationViolation
} }
if (pinViolationIdentityChange != null) { if (maybeIdentityChangeViolation != null) {
ComposerAlertMolecule( ComposerAlertMolecule(
modifier = modifier, modifier = modifier,
avatar = pinViolationIdentityChange.identityRoomMember.avatarData, avatar = maybeIdentityChangeViolation.identityRoomMember.avatarData,
content = buildAnnotatedString { content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more) val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val displayName = pinViolationIdentityChange.identityRoomMember.displayNameOrDefault val displayName = maybeIdentityChangeViolation.identityRoomMember.displayNameOrDefault
val userIdStr = stringResource( val userIdStr = stringResource(
CommonStrings.crypto_identity_change_pin_violation_new_user_id, 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( stringResource(
id = CommonStrings.crypto_identity_change_pin_violation_new, id = CommonStrings.crypto_identity_change_pin_violation_new,
displayName, displayName,
@ -93,19 +92,19 @@ fun IdentityChangeStateView(
end = learnMoreStartIndex + learnMoreStr.length, end = learnMoreStartIndex + learnMoreStr.length,
) )
}, },
submitText = if (pinViolationIdentityChange.identityState == IdentityState.VerificationViolation) { submitText = if (maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation) {
stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action) stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action)
} else { } else {
stringResource(CommonStrings.action_ok) stringResource(CommonStrings.action_ok)
}, },
onSubmitClick = { onSubmitClick = {
if (pinViolationIdentityChange.identityState == IdentityState.VerificationViolation) { if (maybeIdentityChangeViolation.identityState == IdentityState.VerificationViolation) {
state.eventSink(IdentityChangeEvent.VerificationViolation(pinViolationIdentityChange.identityRoomMember.userId)) state.eventSink(IdentityChangeEvent.WithdrawVerification(maybeIdentityChangeViolation.identityRoomMember.userId))
} else { } 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,
) )
} }
} }

View file

@ -403,7 +403,7 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
val roomMemberIdentityStateChange by produceState(persistentListOf()) { val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange() observeRoomMemberIdentityStateChange(room)
} }
return MessageComposerState( return MessageComposerState(

View file

@ -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<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
}
.launchIn(this)
}

View file

@ -143,7 +143,7 @@ class IdentityChangeStatePresenterTest {
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(IdentityChangeEvent.PinViolation(A_USER_ID)) initialState.eventSink(IdentityChangeEvent.PinIdentity(A_USER_ID))
lambda.assertions().isCalledOnce().with(value(A_USER_ID)) lambda.assertions().isCalledOnce().with(value(A_USER_ID))
} }
} }
@ -158,7 +158,7 @@ class IdentityChangeStatePresenterTest {
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(IdentityChangeEvent.VerificationViolation(A_USER_ID)) initialState.eventSink(IdentityChangeEvent.WithdrawVerification(A_USER_ID))
lambda.assertions().isCalledOnce().with(value(A_USER_ID)) lambda.assertions().isCalledOnce().with(value(A_USER_ID))
} }
} }

View file

@ -47,7 +47,7 @@ class IdentityChangeStateViewTest {
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.action_ok) rule.clickOn(res = CommonStrings.action_ok)
eventsRecorder.assertSingle(IdentityChangeEvent.PinViolation(UserId("@alice:localhost"))) eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost")))
} }
@Test @Test
@ -70,7 +70,7 @@ class IdentityChangeStateViewTest {
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action) 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 @Test

View file

@ -66,7 +66,15 @@ interface EncryptionService {
* Remember this identity, ensuring it does not result in a pin violation. * Remember this identity, ensuring it does not result in a pin violation.
*/ */
suspend fun pinUserIdentity(userId: UserId): Result<Unit> suspend fun pinUserIdentity(userId: UserId): Result<Unit>
suspend fun withdrawVerificationRequirement(userId: UserId): Result<Unit>
/**
* Withdraw the verification for that user (also pin the identity).
*
* Useful when a user that was verified is not anymore, but it is not
* possible to re-verify immediately. This allows to restore communication by reverting the
* user trust from verified to TOFU verified.
*/
suspend fun withdrawVerification(userId: UserId): Result<Unit>
} }
/** /**

View file

@ -209,7 +209,7 @@ internal class RustEncryptionService(
getUserIdentity(userId).pin() getUserIdentity(userId).pin()
} }
override suspend fun withdrawVerificationRequirement(userId: UserId): Result<Unit> = runCatching { override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).withdrawVerification() getUserIdentity(userId).withdrawVerification()
} }

View file

@ -125,7 +125,7 @@ class FakeEncryptionService(
return pinUserIdentityResult(userId) return pinUserIdentityResult(userId)
} }
override suspend fun withdrawVerificationRequirement(userId: UserId): Result<Unit> { override suspend fun withdrawVerification(userId: UserId): Result<Unit> {
return withdrawVerificationResult(userId) return withdrawVerificationResult(userId)
} }