Merge pull request #4126 from element-hq/feature/valere/support_verification_violation_banner
feature(crypto): verification violation handling and block sending
This commit is contained in:
commit
bae6e964db
29 changed files with 476 additions and 143 deletions
|
|
@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
|
|
@ -78,6 +79,7 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -154,7 +156,9 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
var hasDismissedInviteDialog by rememberSaveable {
|
var hasDismissedInviteDialog by rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
val roomMemberIdentityStateChanges by produceState(persistentListOf()) {
|
||||||
|
observeRoomMemberIdentityStateChange(room)
|
||||||
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Remove the unread flag on entering but don't send read receipts
|
// Remove the unread flag on entering but don't send read receipts
|
||||||
// as those will be handled by the timeline.
|
// as those will be handled by the timeline.
|
||||||
|
|
@ -211,6 +215,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
roomAvatar = roomAvatar,
|
roomAvatar = roomAvatar,
|
||||||
heroes = heroes,
|
heroes = heroes,
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
|
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||||
userEventPermissions = userEventPermissions,
|
userEventPermissions = userEventPermissions,
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
|
|
@ -33,6 +34,7 @@ data class MessagesState(
|
||||||
val heroes: ImmutableList<AvatarData>,
|
val heroes: ImmutableList<AvatarData>,
|
||||||
val userEventPermissions: UserEventPermissions,
|
val userEventPermissions: UserEventPermissions,
|
||||||
val composerState: MessageComposerState,
|
val composerState: MessageComposerState,
|
||||||
|
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val timelineProtectionState: TimelineProtectionState,
|
val timelineProtectionState: TimelineProtectionState,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
|
|
@ -41,6 +42,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
|
@ -92,6 +94,7 @@ fun aMessagesState(
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal,
|
mode = MessageComposerMode.Normal,
|
||||||
),
|
),
|
||||||
|
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
|
||||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||||
timelineState: TimelineState = aTimelineState(
|
timelineState: TimelineState = aTimelineState(
|
||||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
|
@ -117,6 +120,7 @@ fun aMessagesState(
|
||||||
heroes = persistentListOf(),
|
heroes = persistentListOf(),
|
||||||
userEventPermissions = userEventPermissions,
|
userEventPermissions = userEventPermissions,
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
|
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
identityChangeState = identityChangeState,
|
identityChangeState = identityChangeState,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
|
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
|
||||||
|
|
@ -97,6 +98,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -425,13 +427,20 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageComposerView(
|
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||||
state = state.composerState,
|
it.identityState == IdentityState.VerificationViolation
|
||||||
voiceMessageState = state.voiceMessageComposerState,
|
}
|
||||||
subcomposing = subcomposing,
|
if (verificationViolation != null) {
|
||||||
enableVoiceMessages = state.enableVoiceMessages,
|
DisabledComposerView(modifier = Modifier.fillMaxWidth())
|
||||||
modifier = Modifier.fillMaxWidth(),
|
} else {
|
||||||
)
|
MessageComposerView(
|
||||||
|
state = state.composerState,
|
||||||
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
|
subcomposing = subcomposing,
|
||||||
|
enableVoiceMessages = state.enableVoiceMessages,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CantSendMessageBanner()
|
CantSendMessageBanner()
|
||||||
|
|
|
||||||
|
|
@ -10,5 +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 Submit(val userId: UserId) : IdentityChangeEvent
|
data class PinIdentity(val userId: UserId) : IdentityChangeEvent
|
||||||
|
data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,16 @@
|
||||||
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.AvatarSize
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.roomMembers
|
|
||||||
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,12 +30,17 @@ 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.Submit -> coroutineScope.pinUserIdentity(event.userId)
|
is IdentityChangeEvent.WithdrawVerification -> {
|
||||||
|
coroutineScope.withdrawVerification(event.userId)
|
||||||
|
}
|
||||||
|
is IdentityChangeEvent.PinIdentity -> {
|
||||||
|
coroutineScope.pinUserIdentity(event.userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,56 +50,17 @@ 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 {
|
||||||
Timber.e(it, "Failed to pin identity for user $userId")
|
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(
|
|
||||||
userId = userId,
|
|
||||||
displayNameOrDefault = displayNameOrDefault,
|
|
||||||
avatarData = getAvatarData(AvatarSize.ComposerAlert),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
|
|
||||||
userId = userId,
|
|
||||||
displayNameOrDefault = userId.extractedDisplayName,
|
|
||||||
avatarData = AvatarData(
|
|
||||||
id = userId.value,
|
|
||||||
name = null,
|
|
||||||
url = null,
|
|
||||||
size = AvatarSize.ComposerAlert,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
|
||||||
roomMemberIdentityStateChanges = listOf(
|
roomMemberIdentityStateChanges = listOf(
|
||||||
aRoomMemberIdentityStateChange(
|
aRoomMemberIdentityStateChange(
|
||||||
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
|
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
|
||||||
identityState = IdentityState.PinViolation,
|
identityState = IdentityState.VerificationViolation,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -47,9 +47,10 @@ internal fun aRoomMemberIdentityStateChange(
|
||||||
|
|
||||||
internal fun anIdentityChangeState(
|
internal fun anIdentityChangeState(
|
||||||
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
|
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
|
||||||
|
eventSink: (IdentityChangeEvent) -> Unit = {},
|
||||||
) = IdentityChangeState(
|
) = IdentityChangeState(
|
||||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
|
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
|
||||||
eventSink = {},
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun anIdentityRoomMember(
|
internal fun anIdentityRoomMember(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.crypto.identity
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -17,10 +18,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.appconfig.LearnMoreConfig
|
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.atomic.molecules.ComposerAlertMolecule
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -29,63 +32,90 @@ 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 a violation
|
||||||
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull {
|
val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||||
// For now only render PinViolation
|
it.identityState.isAViolation()
|
||||||
it.identityState == IdentityState.PinViolation
|
|
||||||
}
|
}
|
||||||
if (pinViolationIdentityChange != null) {
|
when (identityChangeViolation?.identityState) {
|
||||||
ComposerAlertMolecule(
|
IdentityState.PinViolation -> ViolationAlert(
|
||||||
|
identityChangeViolation = identityChangeViolation,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
textId = CommonStrings.crypto_identity_change_pin_violation_new,
|
||||||
|
isCritical = false,
|
||||||
|
submitTextId = CommonStrings.action_ok,
|
||||||
|
onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
avatar = pinViolationIdentityChange.identityRoomMember.avatarData,
|
|
||||||
content = buildAnnotatedString {
|
|
||||||
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
|
|
||||||
val displayName = pinViolationIdentityChange.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,
|
|
||||||
)
|
|
||||||
append(fullText)
|
|
||||||
val userIdStartIndex = fullText.indexOf(userIdStr)
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
),
|
|
||||||
start = userIdStartIndex,
|
|
||||||
end = userIdStartIndex + userIdStr.length,
|
|
||||||
)
|
|
||||||
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
|
|
||||||
addStyle(
|
|
||||||
style = SpanStyle(
|
|
||||||
textDecoration = TextDecoration.Underline,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
),
|
|
||||||
start = learnMoreStartIndex,
|
|
||||||
end = learnMoreStartIndex + learnMoreStr.length,
|
|
||||||
)
|
|
||||||
addLink(
|
|
||||||
url = LinkAnnotation.Url(
|
|
||||||
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
|
|
||||||
linkInteractionListener = {
|
|
||||||
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
start = learnMoreStartIndex,
|
|
||||||
end = learnMoreStartIndex + learnMoreStr.length,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) },
|
|
||||||
isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation,
|
|
||||||
)
|
)
|
||||||
|
IdentityState.VerificationViolation -> ViolationAlert(
|
||||||
|
identityChangeViolation = identityChangeViolation,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
textId = CommonStrings.crypto_identity_change_verification_violation_new,
|
||||||
|
isCritical = true,
|
||||||
|
submitTextId = CommonStrings.crypto_identity_change_withdraw_verification_action,
|
||||||
|
onSubmitClick = { state.eventSink(IdentityChangeEvent.WithdrawVerification(identityChangeViolation.identityRoomMember.userId)) },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ViolationAlert(
|
||||||
|
identityChangeViolation: RoomMemberIdentityStateChange,
|
||||||
|
onLinkClick: (String, Boolean) -> Unit,
|
||||||
|
@StringRes textId: Int,
|
||||||
|
isCritical: Boolean,
|
||||||
|
@StringRes submitTextId: Int,
|
||||||
|
onSubmitClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ComposerAlertMolecule(
|
||||||
|
modifier = modifier,
|
||||||
|
avatar = identityChangeViolation.identityRoomMember.avatarData,
|
||||||
|
content = buildAnnotatedString {
|
||||||
|
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
|
||||||
|
val displayName = identityChangeViolation.identityRoomMember.displayNameOrDefault
|
||||||
|
val userIdStr = stringResource(
|
||||||
|
CommonStrings.crypto_identity_change_pin_violation_new_user_id,
|
||||||
|
identityChangeViolation.identityRoomMember.userId,
|
||||||
|
)
|
||||||
|
val fullText = stringResource(textId, displayName, userIdStr, learnMoreStr)
|
||||||
|
append(fullText)
|
||||||
|
val userIdStartIndex = fullText.indexOf(userIdStr)
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
start = userIdStartIndex,
|
||||||
|
end = userIdStartIndex + userIdStr.length,
|
||||||
|
)
|
||||||
|
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = ElementTheme.colors.textPrimary
|
||||||
|
),
|
||||||
|
start = learnMoreStartIndex,
|
||||||
|
end = learnMoreStartIndex + learnMoreStr.length,
|
||||||
|
)
|
||||||
|
addLink(
|
||||||
|
url = LinkAnnotation.Url(
|
||||||
|
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
|
||||||
|
linkInteractionListener = {
|
||||||
|
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
start = learnMoreStartIndex,
|
||||||
|
end = learnMoreStartIndex + learnMoreStr.length,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
submitText = stringResource(submitTextId),
|
||||||
|
onSubmitClick = onSubmitClick,
|
||||||
|
isCritical = isCritical,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun IdentityChangeStateViewPreview(
|
internal fun IdentityChangeStateViewPreview(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -389,6 +389,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* 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.IdentityRoomMember
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.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.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
|
||||||
|
userId = userId,
|
||||||
|
displayNameOrDefault = displayNameOrDefault,
|
||||||
|
avatarData = getAvatarData(AvatarSize.ComposerAlert),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
|
||||||
|
userId = userId,
|
||||||
|
displayNameOrDefault = userId.extractedDisplayName,
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = userId.value,
|
||||||
|
name = null,
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.ComposerAlert,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -139,6 +139,7 @@ internal fun MessageComposerViewPreview(
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
|
DisabledComposerView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,22 @@ 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.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 withdraws the identity, the presenter invokes the encryption service api`() =
|
||||||
|
runTest {
|
||||||
|
val lambda = lambdaRecorder<UserId, Result<Unit>> { 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))
|
lambda.assertions().isCalledOnce().with(value(A_USER_ID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `show and resolve pin violation`() {
|
||||||
|
val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
|
||||||
|
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<IdentityChangeEvent>()
|
||||||
|
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView(
|
||||||
|
state: IdentityChangeState,
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
IdentityChangeStateView(
|
||||||
|
state = state,
|
||||||
|
onLinkClick = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,11 @@ fun ComposerAlertMolecule(
|
||||||
text = content,
|
text = content,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = if (isCritical) {
|
||||||
|
ElementTheme.colors.textCriticalPrimary
|
||||||
|
} else {
|
||||||
|
ElementTheme.colors.textPrimary
|
||||||
|
},
|
||||||
textAlign = TextAlign.Start,
|
textAlign = TextAlign.Start,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +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>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,5 @@ enum class IdentityState {
|
||||||
*/
|
*/
|
||||||
VerificationViolation,
|
VerificationViolation,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun IdentityState.isAViolation() = this == IdentityState.PinViolation || this == IdentityState.VerificationViolation
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,10 @@ internal class RustEncryptionService(
|
||||||
getUserIdentity(userId).pin()
|
getUserIdentity(userId).pin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
|
||||||
|
getUserIdentity(userId).withdrawVerification()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
|
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
|
||||||
return service.userIdentity(
|
return service.userIdentity(
|
||||||
userId = userId.value,
|
userId = userId.value,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class FakeEncryptionService(
|
||||||
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
||||||
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||||
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
|
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
|
||||||
|
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||||
) : EncryptionService {
|
) : EncryptionService {
|
||||||
private var disableRecoveryFailure: Exception? = null
|
private var disableRecoveryFailure: Exception? = null
|
||||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||||
|
|
@ -124,6 +125,10 @@ class FakeEncryptionService(
|
||||||
return pinUserIdentityResult(userId)
|
return pinUserIdentityResult(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun withdrawVerification(userId: UserId): Result<Unit> {
|
||||||
|
return withdrawVerificationResult(userId)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = simulateLongTask {
|
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = simulateLongTask {
|
||||||
isUserVerifiedResult(userId)
|
isUserVerifiedResult(userId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3ffda5a15b4c2a56009940901b0f5b102536efbc77a540c9814c4e644618509e
|
oid sha256:0bceb89945fbdc84bfd2778d5061b61b5602a147c8c4325f1893b9a2e5dd411e
|
||||||
size 25186
|
size 27588
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9370c42c42cbb9878244c4a1318d7e59725afc90ab1bca818a2a2619a41a6479
|
oid sha256:b098eebc72bc5388453bc7a8ba46e62fb64b994b5240dda4bf1f59161362e97a
|
||||||
size 27931
|
size 29408
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ab8c08410091863015fdf8e9cfeaf4259f470b500d7901d3dffff83483fb53a3
|
oid sha256:62baf6570e7b118a943efd1aa7482d865a3984646cc41afa28a8dccd6246b580
|
||||||
size 65168
|
size 68313
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ee7cdf903b25290a383c19ffa8f55759449469019f4b387366a0c538d59cc4b8
|
oid sha256:66343e5a2a84a1caf4d4606ec3985866be85ee4d4a0b02d8fd374364c561f411
|
||||||
size 69210
|
size 70575
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0d866ee561b4109bf1bb4d1cc3b34d32ad0c67ae51f7599088898acd4310f961
|
||||||
|
size 6725
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3fcb7d90743e35ebf03f09129e02f4df7d0b3b883c451fb45f57f70669b5d4c5
|
||||||
|
size 6382
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:409936daa72c7e70334f2ff5d62e84e41b5b79277fefb064b4a9183bd3925549
|
oid sha256:ceb6210817e79599a968157aebb7fd1d4e80ddbb8f3e57aabfb58d8f6066b9d4
|
||||||
size 16357
|
size 19455
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:99c74f986295bd66f52346723a562a8d87b4eca9bda021220e8e916fecc86784
|
oid sha256:6394543bdd107f70a09bdcb164163984112a25f90eed93850cb525572dd53045
|
||||||
size 15106
|
size 17888
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:267bd9c09da1732222fc833e302b06c2107a2964524e8f98f5fdd7d2884543a5
|
oid sha256:ff4fbcc142dac5a501eabb228009663eed497a2d30d8e29e0fe68b462a82997d
|
||||||
size 20808
|
size 20369
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:35499aea0d8741002ee546553ac0ec8fee64abeb93db8913e585804ed28539fc
|
oid sha256:570d33be676b5a603f13c1beb364d41e8c19a6d1c501c3082cc197cd26344d4e
|
||||||
size 23451
|
size 22546
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue