Benoit Marty
2 weeks ago
committed by
Benoit Marty
18 changed files with 509 additions and 1 deletions
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId |
||||
|
||||
sealed interface IdentityChangeEvent { |
||||
data class Submit(val userId: UserId) : IdentityChangeEvent |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState |
||||
import io.element.android.libraries.matrix.api.room.RoomMember |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
data class IdentityChangeState( |
||||
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>, |
||||
val eventSink: (IdentityChangeEvent) -> Unit, |
||||
) |
||||
|
||||
data class RoomMemberIdentityStateChange( |
||||
val roomMember: RoomMember, |
||||
val identityState: IdentityState, |
||||
) |
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.saveable.rememberSaveable |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import io.element.android.libraries.matrix.api.core.UserId |
||||
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.RoomMembershipState |
||||
import io.element.android.libraries.matrix.api.room.roomMembers |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.flow.combine |
||||
import kotlinx.coroutines.flow.distinctUntilChanged |
||||
import kotlinx.coroutines.flow.launchIn |
||||
import kotlinx.coroutines.flow.onEach |
||||
import javax.inject.Inject |
||||
|
||||
class IdentityChangeStatePresenter @Inject constructor( |
||||
private val room: MatrixRoom, |
||||
) : Presenter<IdentityChangeState> { |
||||
@Composable |
||||
override fun present(): IdentityChangeState { |
||||
val roomMemberIdentityStateChange = remember { |
||||
mutableStateOf(emptyList<RoomMemberIdentityStateChange>()) |
||||
} |
||||
|
||||
// Keep the ignored alert locally for now |
||||
val ignoredUserIdChange = rememberSaveable { |
||||
mutableStateOf(emptyList<UserId>()) |
||||
} |
||||
|
||||
LaunchedEffect(Unit) { |
||||
observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange) |
||||
} |
||||
|
||||
fun handleEvent(event: IdentityChangeEvent) { |
||||
when (event) { |
||||
is IdentityChangeEvent.Submit -> { |
||||
ignoredUserIdChange.value += event.userId |
||||
// TODO notify the SDK |
||||
} |
||||
} |
||||
} |
||||
|
||||
return IdentityChangeState( |
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChange.value |
||||
.filter { it.roomMember.userId !in ignoredUserIdChange.value } |
||||
.toImmutableList(), |
||||
eventSink = ::handleEvent, |
||||
) |
||||
} |
||||
|
||||
private fun CoroutineScope.observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange: MutableState<List<RoomMemberIdentityStateChange>>) { |
||||
combine(room.identityStateChangesFlow, room.membersStateFlow) { IdentityStateChanges, membersState -> |
||||
IdentityStateChanges.map { IdentityStateChange -> |
||||
val member = membersState.roomMembers() |
||||
?.firstOrNull { roomMember -> roomMember.userId == IdentityStateChange.userId } |
||||
?: createDefaultRoomMemberForIdentityChange(IdentityStateChange.userId) |
||||
RoomMemberIdentityStateChange( |
||||
roomMember = member, |
||||
identityState = IdentityStateChange.identityState, |
||||
) |
||||
} |
||||
} |
||||
.distinctUntilChanged() |
||||
.onEach { roomMemberIdentityStateChanges -> |
||||
roomMemberIdentityStateChange.value = roomMemberIdentityStateChanges |
||||
} |
||||
.launchIn(this) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a default [RoomMember] for identity change events. |
||||
* In this case, only the userId will be used for rendering, other fields are not used, but keep them |
||||
* as close as possible to the actual data. |
||||
*/ |
||||
private fun createDefaultRoomMemberForIdentityChange(userId: UserId): RoomMember { |
||||
return RoomMember( |
||||
userId = userId, |
||||
displayName = null, |
||||
avatarUrl = null, |
||||
membership = RoomMembershipState.JOIN, |
||||
isNameAmbiguous = false, |
||||
powerLevel = 0, |
||||
normalizedPowerLevel = 0, |
||||
isIgnored = false, |
||||
role = RoomMember.Role.USER, |
||||
) |
||||
} |
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.features.messages.impl.typing.aTypingRoomMember |
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
|
||||
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> { |
||||
override val values: Sequence<IdentityChangeState> |
||||
get() = sequenceOf( |
||||
anIdentityChangeState(), |
||||
anIdentityChangeState( |
||||
roomMemberIdentityStateChanges = listOf( |
||||
RoomMemberIdentityStateChange( |
||||
roomMember = aTypingRoomMember(displayName = "Alice"), |
||||
identityState = IdentityState.PinViolation, |
||||
), |
||||
), |
||||
), |
||||
) |
||||
} |
||||
|
||||
internal fun anIdentityChangeState( |
||||
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(), |
||||
) = IdentityChangeState( |
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), |
||||
eventSink = {}, |
||||
) |
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.text.SpanStyle |
||||
import androidx.compose.ui.text.buildAnnotatedString |
||||
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.libraries.designsystem.atomic.molecules.ComposerAlertMolecule |
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize |
||||
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.matrix.ui.model.getAvatarData |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
|
||||
@Composable |
||||
fun IdentityChangeStateView( |
||||
state: IdentityChangeState, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
// Pick the first identity change to PinViolation |
||||
val identityChange = state.roomMemberIdentityStateChanges.firstOrNull { |
||||
// For now only render PinViolation |
||||
it.identityState == IdentityState.PinViolation |
||||
} |
||||
if (identityChange != null) { |
||||
ComposerAlertMolecule( |
||||
modifier = modifier, |
||||
avatar = identityChange.roomMember.getAvatarData(AvatarSize.ComposerAlert), |
||||
content = buildAnnotatedString { |
||||
val coloredPart = stringResource(CommonStrings.action_learn_more) |
||||
val fullText = stringResource( |
||||
CommonStrings.crypto_identity_change_pin_violation, |
||||
identityChange.roomMember.disambiguatedDisplayName, |
||||
coloredPart, |
||||
) |
||||
val startIndex = fullText.indexOf(coloredPart) |
||||
append(fullText) |
||||
addStyle( |
||||
style = SpanStyle( |
||||
textDecoration = TextDecoration.Underline, |
||||
fontWeight = FontWeight.Bold, |
||||
), |
||||
start = startIndex, |
||||
end = startIndex + coloredPart.length, |
||||
) |
||||
addStringAnnotation( |
||||
tag = "LEARN_MORE", |
||||
annotation = "TODO", |
||||
start = startIndex, |
||||
end = startIndex + coloredPart.length |
||||
) |
||||
}, |
||||
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(identityChange.roomMember.userId)) }, |
||||
isCritical = identityChange.identityState == IdentityState.VerificationViolation, |
||||
) |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun IdentityChangeStateViewPreview( |
||||
@PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState, |
||||
) = ElementPreview { |
||||
IdentityChangeStateView( |
||||
state = state, |
||||
) |
||||
} |
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import io.element.android.features.messages.impl.MessagesView |
||||
import io.element.android.features.messages.impl.aMessagesState |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun MessagesViewWithIdentityChangePreview( |
||||
@PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState |
||||
) = ElementPreview { |
||||
MessagesView( |
||||
state = aMessagesState().copy(identityChangeState = identityChangeState), |
||||
onBackClick = {}, |
||||
onRoomDetailsClick = {}, |
||||
onEventClick = { false }, |
||||
onUserDataClick = {}, |
||||
onLinkClick = {}, |
||||
onPreviewAttachments = {}, |
||||
onSendLocationClick = {}, |
||||
onCreatePollClick = {}, |
||||
onJoinCallClick = {}, |
||||
onViewAllPinnedMessagesClick = {}, |
||||
) |
||||
} |
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
/* |
||||
* Copyright 2024 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.crypto.identity |
||||
|
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.features.messages.impl.typing.aTypingRoomMember |
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState |
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange |
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom |
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState |
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2 |
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom |
||||
import io.element.android.tests.testutils.WarmUpRule |
||||
import io.element.android.tests.testutils.test |
||||
import kotlinx.collections.immutable.toImmutableList |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
class IdentityChangeStatePresenterTest { |
||||
@get:Rule |
||||
val warmUpRule = WarmUpRule() |
||||
|
||||
@Test |
||||
fun `present - initial state`() = runTest { |
||||
val presenter = createIdentityChangeStatePresenter() |
||||
presenter.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - when the room emits identity change, the presenter emits new state`() = runTest { |
||||
val room = FakeMatrixRoom() |
||||
val presenter = createIdentityChangeStatePresenter(room) |
||||
presenter.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() |
||||
room.emitIdentityStateChanges( |
||||
listOf( |
||||
IdentityStateChange( |
||||
userId = A_USER_ID_2, |
||||
identityState = IdentityState.PinViolation, |
||||
), |
||||
) |
||||
) |
||||
val finalItem = awaitItem() |
||||
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) |
||||
val value = finalItem.roomMemberIdentityStateChanges.first() |
||||
assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2) |
||||
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - when the room emits identity change, the presenter emits new state with member details`() = |
||||
runTest { |
||||
val room = FakeMatrixRoom().apply { |
||||
givenRoomMembersState( |
||||
MatrixRoomMembersState.Ready( |
||||
listOf( |
||||
aTypingRoomMember( |
||||
A_USER_ID_2, |
||||
displayName = "Alice", |
||||
), |
||||
).toImmutableList() |
||||
) |
||||
) |
||||
} |
||||
val presenter = createIdentityChangeStatePresenter(room) |
||||
presenter.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() |
||||
room.emitIdentityStateChanges( |
||||
listOf( |
||||
IdentityStateChange( |
||||
userId = A_USER_ID_2, |
||||
identityState = IdentityState.PinViolation, |
||||
), |
||||
) |
||||
) |
||||
val finalItem = awaitItem() |
||||
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) |
||||
val value = finalItem.roomMemberIdentityStateChanges.first() |
||||
assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2) |
||||
assertThat(value.roomMember.displayName).isEqualTo("Alice") |
||||
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) |
||||
} |
||||
} |
||||
|
||||
private fun createIdentityChangeStatePresenter( |
||||
room: MatrixRoom = FakeMatrixRoom(), |
||||
): IdentityChangeStatePresenter { |
||||
return IdentityChangeStatePresenter( |
||||
room = room, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
/* |
||||
* Copyright 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.libraries.matrix.api.encryption.identity |
||||
|
||||
enum class IdentityState { |
||||
/** The user is verified with us */ |
||||
Verified, |
||||
|
||||
/** |
||||
* Either this is the first identity we have seen for this user, or the |
||||
* user has acknowledged a change of identity explicitly e.g. by |
||||
* clicking OK on a notification. |
||||
*/ |
||||
Pinned, |
||||
|
||||
/** |
||||
* The user's identity has changed since it was pinned. The user should be |
||||
* notified about this and given the opportunity to acknowledge the |
||||
* change, which will make the new identity pinned. |
||||
*/ |
||||
PinViolation, |
||||
|
||||
/** |
||||
* The user's identity has changed, and before that it was verified. This |
||||
* is a serious problem. The user can either verify again to make this |
||||
* identity verified, or withdraw verification to make it pinned. |
||||
*/ |
||||
VerificationViolation, |
||||
} |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
/* |
||||
* Copyright 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.libraries.matrix.api.encryption.identity |
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId |
||||
|
||||
data class IdentityStateChange( |
||||
val userId: UserId, |
||||
val identityState: IdentityState, |
||||
) |
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
/* |
||||
* Copyright 2024 New Vector Ltd. |
||||
* |
||||
* SPDX-License-Identifier: AGPL-3.0-only |
||||
* Please see LICENSE in the repository root for full details. |
||||
*/ |
||||
|
||||
package io.element.android.libraries.matrix.impl.mapper |
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState |
||||
import org.matrix.rustcomponents.sdk.IdentityState as RustIdentityState |
||||
|
||||
fun RustIdentityState.map(): IdentityState = when (this) { |
||||
RustIdentityState.VERIFIED -> IdentityState.Verified |
||||
RustIdentityState.PINNED -> IdentityState.Pinned |
||||
RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation |
||||
RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation |
||||
} |
Loading…
Reference in new issue