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
e128eca991
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.messagecomposer.MessageComposerEvents
|
||||
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.timeline.TimelineController
|
||||
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.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -154,7 +156,9 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
var hasDismissedInviteDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val roomMemberIdentityStateChanges by produceState(persistentListOf()) {
|
||||
observeRoomMemberIdentityStateChange(room)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Remove the unread flag on entering but don't send read receipts
|
||||
// as those will be handled by the timeline.
|
||||
|
|
@ -211,6 +215,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
composerState = composerState,
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||
userEventPermissions = userEventPermissions,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
|
|||
import androidx.compose.runtime.Immutable
|
||||
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.RoomMemberIdentityStateChange
|
||||
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.timeline.TimelineState
|
||||
|
|
@ -33,6 +34,7 @@ data class MessagesState(
|
|||
val heroes: ImmutableList<AvatarData>,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val composerState: MessageComposerState,
|
||||
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
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.anActionListState
|
||||
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.messagecomposer.MessageComposerState
|
||||
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.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
|
|
@ -92,6 +94,7 @@ fun aMessagesState(
|
|||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
|
|
@ -117,6 +120,7 @@ fun aMessagesState(
|
|||
heroes = persistentListOf(),
|
||||
userEventPermissions = userEventPermissions,
|
||||
composerState = composerState,
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
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.crypto.identity.IdentityChangeStateView
|
||||
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.MessageComposerView
|
||||
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.matrix.api.core.EventId
|
||||
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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -425,13 +427,20 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||
it.identityState == IdentityState.VerificationViolation
|
||||
}
|
||||
if (verificationViolation != null) {
|
||||
DisabledComposerView(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
CantSendMessageBanner()
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ package io.element.android.features.messages.impl.crypto.identity
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
sealed interface IdentityChangeEvent {
|
||||
data class Submit(val userId: UserId) : IdentityChangeEvent
|
||||
data class PinIdentity(val userId: UserId) : IdentityChangeEvent
|
||||
data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,30 +8,16 @@
|
|||
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
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -44,12 +30,17 @@ class IdentityChangeStatePresenter @Inject constructor(
|
|||
override fun present(): IdentityChangeState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
|
||||
observeRoomMemberIdentityStateChange()
|
||||
observeRoomMemberIdentityStateChange(room)
|
||||
}
|
||||
|
||||
fun handleEvent(event: IdentityChangeEvent) {
|
||||
when (event) {
|
||||
is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId)
|
||||
is IdentityChangeEvent.WithdrawVerification -> {
|
||||
coroutineScope.withdrawVerification(event.userId)
|
||||
}
|
||||
is IdentityChangeEvent.PinIdentity -> {
|
||||
coroutineScope.pinUserIdentity(event.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,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 {
|
||||
encryptionService.pinUserIdentity(userId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to pin identity for user $userId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.withdrawVerification(userId: UserId) = launch {
|
||||
encryptionService.withdrawVerification(userId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to withdraw verification for user $userId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
|
||||
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(
|
||||
aRoomMemberIdentityStateChange(
|
||||
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
|
||||
identityState = IdentityState.PinViolation,
|
||||
identityState = IdentityState.VerificationViolation,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -47,9 +47,10 @@ internal fun aRoomMemberIdentityStateChange(
|
|||
|
||||
internal fun anIdentityChangeState(
|
||||
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
|
||||
eventSink: (IdentityChangeEvent) -> Unit = {},
|
||||
) = IdentityChangeState(
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
|
||||
eventSink = {},
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun anIdentityRoomMember(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.crypto.identity
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.tooling.preview.PreviewParameter
|
||||
import io.element.android.appconfig.LearnMoreConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
|
@ -29,63 +32,90 @@ fun IdentityChangeStateView(
|
|||
onLinkClick: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Pick the first identity change to PinViolation
|
||||
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||
// For now only render PinViolation
|
||||
it.identityState == IdentityState.PinViolation
|
||||
// Pick the first identity change that is a violation
|
||||
val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||
it.identityState.isAViolation()
|
||||
}
|
||||
if (pinViolationIdentityChange != null) {
|
||||
ComposerAlertMolecule(
|
||||
when (identityChangeViolation?.identityState) {
|
||||
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,
|
||||
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
|
||||
@Composable
|
||||
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(
|
||||
textEditorState = textEditorState,
|
||||
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,
|
||||
subcomposing = false,
|
||||
)
|
||||
DisabledComposerView()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,22 @@ class IdentityChangeStatePresenterTest {
|
|||
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(IdentityChangeEvent.Submit(A_USER_ID))
|
||||
initialState.eventSink(IdentityChangeEvent.PinIdentity(A_USER_ID))
|
||||
lambda.assertions().isCalledOnce().with(value(A_USER_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when the user 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
color = if (isCritical) {
|
||||
ElementTheme.colors.textCriticalPrimary
|
||||
} else {
|
||||
ElementTheme.colors.textPrimary
|
||||
},
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ interface EncryptionService {
|
|||
* Remember this identity, ensuring it does not result in a pin violation.
|
||||
*/
|
||||
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,
|
||||
}
|
||||
|
||||
fun IdentityState.isAViolation() = this == IdentityState.PinViolation || this == IdentityState.VerificationViolation
|
||||
|
|
|
|||
|
|
@ -209,6 +209,10 @@ internal class RustEncryptionService(
|
|||
getUserIdentity(userId).pin()
|
||||
}
|
||||
|
||||
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
|
||||
getUserIdentity(userId).withdrawVerification()
|
||||
}
|
||||
|
||||
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
|
||||
return service.userIdentity(
|
||||
userId = userId.value,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class FakeEncryptionService(
|
|||
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
||||
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
|
||||
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||
) : EncryptionService {
|
||||
private var disableRecoveryFailure: Exception? = null
|
||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||
|
|
@ -124,6 +125,10 @@ class FakeEncryptionService(
|
|||
return pinUserIdentityResult(userId)
|
||||
}
|
||||
|
||||
override suspend fun withdrawVerification(userId: UserId): Result<Unit> {
|
||||
return withdrawVerificationResult(userId)
|
||||
}
|
||||
|
||||
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = simulateLongTask {
|
||||
isUserVerifiedResult(userId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ffda5a15b4c2a56009940901b0f5b102536efbc77a540c9814c4e644618509e
|
||||
size 25186
|
||||
oid sha256:0bceb89945fbdc84bfd2778d5061b61b5602a147c8c4325f1893b9a2e5dd411e
|
||||
size 27588
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9370c42c42cbb9878244c4a1318d7e59725afc90ab1bca818a2a2619a41a6479
|
||||
size 27931
|
||||
oid sha256:b098eebc72bc5388453bc7a8ba46e62fb64b994b5240dda4bf1f59161362e97a
|
||||
size 29408
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab8c08410091863015fdf8e9cfeaf4259f470b500d7901d3dffff83483fb53a3
|
||||
size 65168
|
||||
oid sha256:62baf6570e7b118a943efd1aa7482d865a3984646cc41afa28a8dccd6246b580
|
||||
size 68313
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee7cdf903b25290a383c19ffa8f55759449469019f4b387366a0c538d59cc4b8
|
||||
size 69210
|
||||
oid sha256:66343e5a2a84a1caf4d4606ec3985866be85ee4d4a0b02d8fd374364c561f411
|
||||
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
|
||||
oid sha256:409936daa72c7e70334f2ff5d62e84e41b5b79277fefb064b4a9183bd3925549
|
||||
size 16357
|
||||
oid sha256:ceb6210817e79599a968157aebb7fd1d4e80ddbb8f3e57aabfb58d8f6066b9d4
|
||||
size 19455
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:99c74f986295bd66f52346723a562a8d87b4eca9bda021220e8e916fecc86784
|
||||
size 15106
|
||||
oid sha256:6394543bdd107f70a09bdcb164163984112a25f90eed93850cb525572dd53045
|
||||
size 17888
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:267bd9c09da1732222fc833e302b06c2107a2964524e8f98f5fdd7d2884543a5
|
||||
size 20808
|
||||
oid sha256:ff4fbcc142dac5a501eabb228009663eed497a2d30d8e29e0fe68b462a82997d
|
||||
size 20369
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:35499aea0d8741002ee546553ac0ec8fee64abeb93db8913e585804ed28539fc
|
||||
size 23451
|
||||
oid sha256:570d33be676b5a603f13c1beb364d41e8c19a6d1c501c3082cc197cd26344d4e
|
||||
size 22546
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue