Merge branch 'develop' into andybalaam/enable-identity-violation-notifs-unconditionally

This commit is contained in:
Andy Balaam 2024-10-31 09:13:20 +00:00 committed by GitHub
commit 7bf6299e6a
305 changed files with 3609 additions and 1449 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.20" /> <option name="version" value="2.0.21" />
</component> </component>
</project> </project>

View file

@ -1,3 +1,19 @@
Changes in Element X v0.7.2 (2024-10-29)
========================================
## What's Changed
### 🙌 Improvements
* Add setting to compress image and video by @bmarty in https://github.com/element-hq/element-x-android/pull/3744
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3743
### 🧱 Build
* Release script improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/3741
### Dependency upgrades
* Update dependency org.maplibre.gl:android-sdk to v11.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3720
* Update dependency io.sentry:sentry-android to v7.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3726
* Update dependencyAnalysis to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3740
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.58 by @renovate in https://github.com/element-hq/element-x-android/pull/3749
Changes in Element X v0.7.1 (2024-10-25) Changes in Element X v0.7.1 (2024-10-25)
======================================== ========================================

View file

@ -25,8 +25,10 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
@ -50,6 +52,7 @@ import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -66,6 +69,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint, private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase, private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
) )
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
}
}
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
// TODO We do not support Space yet, so directly navigate to main space // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(coroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener)
ftueService.state ftueService.state
.onEach { ftueState -> .onEach { ftueState ->
@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService().setListener(null)
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -260,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onSetUpRecoveryClick() { override fun onSetUpRecoveryClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
} }
override fun onSessionConfirmRecoveryKeyClick() { override fun onSessionConfirmRecoveryKeyClick() {
@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
.callback(object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
})
.build()
}
} }
} }

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.16") detektPlugins("io.nlopez.compose.rules:detekt:0.4.17")
} }
// KtLint // KtLint

View file

@ -35,7 +35,7 @@ class DefaultFtueServiceTest {
@Test @Test
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest { fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.Unknown) emitVerifiedStatus(SessionVerifiedStatus.Unknown)
} }
val service = createDefaultFtueService( val service = createDefaultFtueService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -46,7 +46,7 @@ class DefaultFtueServiceTest {
assertThat(awaitItem()).isEqualTo(FtueState.Unknown) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
// Verification state is known, we should display the flow if any check is false // Verification state is known, we should display the flow if any check is false
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete) assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
} }
} }
@ -64,7 +64,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -76,7 +76,7 @@ class DefaultFtueServiceTest {
@Test @Test
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@ -91,7 +91,7 @@ class DefaultFtueServiceTest {
// Session verification // Session verification
steps.add(service.getNextStep(steps.lastOrNull())) steps.add(service.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in // Notifications opt in
steps.add(service.getNextStep(steps.lastOrNull())) steps.add(service.getNextStep(steps.lastOrNull()))
@ -132,7 +132,7 @@ class DefaultFtueServiceTest {
) )
// Skip first 3 steps // Skip first 3 steps
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -155,7 +155,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)

View file

@ -15,5 +15,5 @@ import kotlinx.parcelize.Parcelize
@Immutable @Immutable
sealed interface Attachment : Parcelable { sealed interface Attachment : Parcelable {
@Parcelize @Parcelize
data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment data class Media(val localMedia: LocalMedia) : Attachment
} }

View file

@ -96,7 +96,6 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaSender.sendMedia( mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri, uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType, mimeType = mediaAttachment.localMedia.info.mimeType,
compressIfPossible = mediaAttachment.compressIfPossible,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
}.fold( }.fold(

View file

@ -31,7 +31,6 @@ fun anAttachmentsPreviewState(
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
compressIfPossible = true
), ),
sendActionState = sendActionState, sendActionState = sendActionState,
eventSink = {} eventSink = {}

View file

@ -169,7 +169,7 @@ class MessageComposerPresenter @Inject constructor(
handlePickedMedia(attachmentsState, uri, mimeType) handlePickedMedia(attachmentsState, uri, mimeType)
} }
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri -> val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
handlePickedMedia(attachmentsState, uri, compressIfPossible = false) handlePickedMedia(attachmentsState, uri)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG) handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
@ -294,7 +294,6 @@ class MessageComposerPresenter @Inject constructor(
name = null, name = null,
formattedFileSize = null formattedFileSize = null
), ),
compressIfPossible = true
), ),
attachmentState = attachmentsState, attachmentState = attachmentsState,
) )
@ -493,7 +492,6 @@ class MessageComposerPresenter @Inject constructor(
attachmentsState: MutableState<AttachmentsState>, attachmentsState: MutableState<AttachmentsState>,
uri: Uri?, uri: Uri?,
mimeType: String? = null, mimeType: String? = null,
compressIfPossible: Boolean = true,
) { ) {
if (uri == null) { if (uri == null) {
attachmentsState.value = AttachmentsState.None attachmentsState.value = AttachmentsState.None
@ -505,7 +503,7 @@ class MessageComposerPresenter @Inject constructor(
name = null, name = null,
formattedFileSize = null formattedFileSize = null
) )
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val mediaAttachment = Attachment.Media(localMedia)
val isPreviewable = when { val isPreviewable = when {
MimeTypes.isImage(localMedia.info.mimeType) -> true MimeTypes.isImage(localMedia.info.mimeType) -> true
MimeTypes.isVideo(localMedia.info.mimeType) -> true MimeTypes.isVideo(localMedia.info.mimeType) -> true
@ -535,7 +533,6 @@ class MessageComposerPresenter @Inject constructor(
mediaSender.sendMedia( mediaSender.sendMedia(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
compressIfPossible = false,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
} }

View file

@ -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.timeline.components
enum class ContentPadding {
Textual,
Media,
CaptionedMedia
}

View file

@ -522,32 +522,33 @@ private fun MessageEventBubbleContent(
fun CommonLayout( fun CommonLayout(
timestampPosition: TimestampPosition, timestampPosition: TimestampPosition,
showThreadDecoration: Boolean, showThreadDecoration: Boolean,
paddingBehaviour: ContentPadding,
inReplyToDetails: InReplyToDetails?, inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canShrinkContent: Boolean = false, canShrinkContent: Boolean = false,
) { ) {
val timestampLayoutModifier: Modifier val timestampLayoutModifier =
val contentModifier: Modifier if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) {
when { Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
inReplyToDetails != null -> { } else {
if (timestampPosition == TimestampPosition.Overlay) { Modifier
timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) }
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
val topPadding = if (inReplyToDetails != null) 0.dp else 8.dp
val contentModifier = when (paddingBehaviour) {
ContentPadding.Textual ->
Modifier.padding(start = 12.dp, end = 12.dp, top = topPadding, bottom = 8.dp)
ContentPadding.Media -> {
if (inReplyToDetails == null) {
Modifier
} else { } else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp) Modifier.clip(RoundedCornerShape(10.dp))
timestampLayoutModifier = Modifier
} }
} }
timestampPosition != TimestampPosition.Overlay -> { ContentPadding.CaptionedMedia ->
timestampLayoutModifier = Modifier Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
contentModifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
else -> {
timestampLayoutModifier = Modifier
contentModifier = Modifier
}
} }
val threadDecoration = @Composable { val threadDecoration = @Composable {
if (showThreadDecoration) { if (showThreadDecoration) {
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp)) ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
@ -601,9 +602,17 @@ private fun MessageEventBubbleContent(
is TimelineItemPollContent -> TimestampPosition.Below is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default else -> TimestampPosition.Default
} }
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
CommonLayout( CommonLayout(
showThreadDecoration = event.isThreaded, showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier.semantics(mergeDescendants = true) { modifier = bubbleModifier.semantics(mergeDescendants = true) {

View file

@ -50,7 +50,9 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
isMine = it, isMine = it,
timelineItemReactions = aTimelineItemReactions(count = 0), timelineItemReactions = aTimelineItemReactions(count = 0),
content = aTimelineItemImageContent( content = aTimelineItemImageContent(
aspectRatio = 2.5f aspectRatio = 2.5f,
filename = "image.jpg",
caption = "A reply with an image.",
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,

View file

@ -69,9 +69,7 @@ fun TimelineItemImageView(
modifier = modifier.semantics { contentDescription = description }, modifier = modifier.semantics { contentDescription = description },
) { ) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier.clip(RoundedCornerShape(10.dp))
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
} else { } else {
Modifier Modifier
} }
@ -119,6 +117,7 @@ fun TimelineItemImageView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText( EditorStyledText(
modifier = Modifier modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption, text = caption,
style = ElementRichTextEditorStyle.textStyle(), style = ElementRichTextEditorStyle.textStyle(),

View file

@ -137,6 +137,7 @@ fun TimelineItemVideoView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText( EditorStyledText(
modifier = Modifier modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption, text = caption,
style = ElementRichTextEditorStyle.textStyle(), style = ElementRichTextEditorStyle.textStyle(),

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import java.util.TimeZone
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> { open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
override val values: Sequence<AggregatedReaction> override val values: Sequence<AggregatedReaction>
@ -29,7 +30,9 @@ fun anAggregatedReaction(
count: Int = 1, count: Int = 1,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
): AggregatedReaction { ): AggregatedReaction {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val date = Date(1_689_061_264L) val date = Date(1_689_061_264L)
val senders = buildList { val senders = buildList {
repeat(count) { index -> repeat(count) { index ->

View file

@ -17,7 +17,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -26,6 +28,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk import io.mockk.mockk
@ -33,6 +36,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.File
class AttachmentsPreviewPresenterTest { class AttachmentsPreviewPresenterTest {
@get:Rule @get:Rule
@ -43,7 +47,7 @@ class AttachmentsPreviewPresenterTest {
@Test @Test
fun `present - send media success scenario`() = runTest { fun `present - send media success scenario`() = runTest {
val sendMediaResult = lambdaRecorder<ProgressCallback?, Result<FakeMediaUploadHandler>> { val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler()) Result.success(FakeMediaUploadHandler())
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
@ -52,7 +56,7 @@ class AttachmentsPreviewPresenterTest {
Pair(5, 10), Pair(5, 10),
Pair(10, 10) Pair(10, 10)
), ),
sendMediaResult = sendMediaResult, sendFileResult = sendFileResult,
) )
val presenter = createAttachmentsPreviewPresenter(room = room) val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -67,18 +71,18 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done) assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendMediaResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
} }
} }
@Test @Test
fun `present - send media failure scenario`() = runTest { fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null) val failure = MediaPreProcessor.Failure(null)
val sendMediaResult = lambdaRecorder<ProgressCallback?, Result<FakeMediaUploadHandler>> { val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.failure(failure) Result.failure(failure)
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendMediaResult = sendMediaResult, sendFileResult = sendFileResult,
) )
val presenter = createAttachmentsPreviewPresenter(room = room) val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -91,7 +95,7 @@ class AttachmentsPreviewPresenterTest {
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure)) assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
sendMediaResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState) failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle)
@ -120,8 +124,8 @@ class AttachmentsPreviewPresenterTest {
room: MatrixRoom = FakeMatrixRoom() room: MatrixRoom = FakeMatrixRoom()
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = Attachment.Media(localMedia, compressIfPossible = false), attachment = aMediaAttachment(localMedia),
mediaSender = MediaSender(mediaPreProcessor, room) mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore())
) )
} }
} }

View file

@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media(
localMedia = localMedia, localMedia = localMedia,
compressIfPossible = compressIfPossible,
) )

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@ -684,7 +685,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Pick file from storage`() = runTest { fun `present - Pick file from storage`() = runTest {
val sendMediaResult = lambdaRecorder { _: ProgressCallback? -> val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler()) Result.success(FakeMediaUploadHandler())
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
@ -693,7 +694,7 @@ class MessageComposerPresenterTest {
Pair(5, 10), Pair(5, 10),
Pair(10, 10) Pair(10, 10)
), ),
sendMediaResult = sendMediaResult, sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(this, room = room) val presenter = createPresenter(this, room = room)
@ -710,7 +711,7 @@ class MessageComposerPresenterTest {
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f)) assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem() val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None) assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
sendMediaResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
} }
} }
@ -852,8 +853,11 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Uploading media failure can be recovered from`() = runTest { fun `present - Uploading media failure can be recovered from`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.failure(Exception())
}
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendMediaResult = { Result.failure(Exception()) }, sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(this, room = room) val presenter = createPresenter(this, room = room)
@ -1489,7 +1493,7 @@ class MessageComposerPresenterTest {
featureFlagService, featureFlagService,
sessionPreferencesStore, sessionPreferencesStore,
localMediaFactory, localMediaFactory,
MediaSender(mediaPreProcessor, room), MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher, snackbarDispatcher,
analyticsService, analyticsService,
DefaultMessageComposerContext(), DefaultMessageComposerContext(),

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@ -30,6 +31,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@ -45,6 +47,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.File
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -56,12 +59,15 @@ class VoiceMessageComposerPresenterTest {
recordingDuration = RECORDING_DURATION recordingDuration = RECORDING_DURATION
) )
private val analyticsService = FakeAnalyticsService() private val analyticsService = FakeAnalyticsService()
private val sendMediaResult = lambdaRecorder<ProgressCallback?, Result<FakeMediaUploadHandler>> { Result.success(FakeMediaUploadHandler()) } private val sendVoiceMessageResult =
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
private val matrixRoom = FakeMatrixRoom( private val matrixRoom = FakeMatrixRoom(
sendMediaResult = sendMediaResult sendVoiceMessageResult = sendVoiceMessageResult
) )
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom) private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom, InMemorySessionPreferencesStore())
private val messageComposerContext = FakeMessageComposerContext() private val messageComposerContext = FakeMessageComposerContext()
companion object { companion object {
@ -291,7 +297,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendMediaResult.assertions().isCalledOnce() sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@ -342,7 +348,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendMediaResult.assertions().isCalledOnce() sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@ -365,7 +371,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendMediaResult.assertions().isCalledOnce() sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@ -389,7 +395,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
sendMediaResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(0) assertThat(analyticsService.trackedErrors).hasSize(0)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -414,13 +420,13 @@ class VoiceMessageComposerPresenterTest {
ensureAllEventsConsumed() ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState()) assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
sendMediaResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
mediaPreProcessor.givenAudioResult() mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendMediaResult.assertions().isCalledOnce() sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@ -457,7 +463,7 @@ class VoiceMessageComposerPresenterTest {
assertThat(showSendFailureDialog).isFalse() assertThat(showSendFailureDialog).isFalse()
} }
sendMediaResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
} }
} }
@ -473,7 +479,7 @@ class VoiceMessageComposerPresenterTest {
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
sendMediaResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(1) assertThat(analyticsService.trackedErrors).hasSize(1)
voiceRecorder.assertCalls(started = 0) voiceRecorder.assertCalls(started = 0)
@ -492,7 +498,7 @@ class VoiceMessageComposerPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start)) initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
sendMediaResult.assertions().isNeverCalled() sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).containsExactly( assertThat(analyticsService.trackedErrors).containsExactly(
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
) )

View file

@ -55,6 +55,7 @@ dependencies {
implementation(projects.features.deactivation.api) implementation(projects.features.deactivation.api)
implementation(projects.features.roomlist.api) implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -12,6 +12,7 @@ import io.element.android.compound.theme.Theme
sealed interface AdvancedSettingsEvents { sealed interface AdvancedSettingsEvents {
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents
data object ChangeTheme : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents
data object CancelChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents

View file

@ -35,6 +35,9 @@ class AdvancedSettingsPresenter @Inject constructor(
val isSharePresenceEnabled by sessionPreferencesStore val isSharePresenceEnabled by sessionPreferencesStore
.isSharePresenceEnabled() .isSharePresenceEnabled()
.collectAsState(initial = true) .collectAsState(initial = true)
val doesCompressMedia by sessionPreferencesStore
.doesCompressMedia()
.collectAsState(initial = false)
val theme by remember { val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
} }
@ -49,6 +52,9 @@ class AdvancedSettingsPresenter @Inject constructor(
is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch {
sessionPreferencesStore.setSharePresence(event.enabled) sessionPreferencesStore.setSharePresence(event.enabled)
} }
is AdvancedSettingsEvents.SetCompressMedia -> localCoroutineScope.launch {
sessionPreferencesStore.setCompressMedia(event.compress)
}
AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false
AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch {
@ -61,6 +67,7 @@ class AdvancedSettingsPresenter @Inject constructor(
return AdvancedSettingsState( return AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
theme = theme, theme = theme,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -12,6 +12,7 @@ import io.element.android.compound.theme.Theme
data class AdvancedSettingsState( data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean, val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean, val isSharePresenceEnabled: Boolean,
val doesCompressMedia: Boolean,
val theme: Theme, val theme: Theme,
val showChangeThemeDialog: Boolean, val showChangeThemeDialog: Boolean,
val eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit

View file

@ -16,18 +16,21 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(), aAdvancedSettingsState(),
aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true), aAdvancedSettingsState(isSharePresenceEnabled = true),
aAdvancedSettingsState(doesCompressMedia = true),
) )
} }
fun aAdvancedSettingsState( fun aAdvancedSettingsState(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
isSendPublicReadReceiptsEnabled: Boolean = false, isSharePresenceEnabled: Boolean = false,
doesCompressMedia: Boolean = false,
showChangeThemeDialog: Boolean = false, showChangeThemeDialog: Boolean = false,
eventSink: (AdvancedSettingsEvents) -> Unit = {}, eventSink: (AdvancedSettingsEvents) -> Unit = {},
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
theme = Theme.System, theme = Theme.System,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = eventSink eventSink = eventSink

View file

@ -11,6 +11,7 @@ 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
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.themes import io.element.android.compound.theme.themes
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
@ -23,6 +24,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -32,6 +35,7 @@ fun AdvancedSettingsView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val analyticsService = LocalAnalyticsService.current
PreferencePage( PreferencePage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
@ -72,6 +76,28 @@ fun AdvancedSettingsView(
), ),
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
) )
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
},
trailingContent = ListItemContent.Switch(
checked = state.doesCompressMedia,
),
onClick = {
val newValue = !state.doesCompressMedia
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
}
)
} }
if (state.showChangeThemeDialog) { if (state.showChangeThemeDialog) {

View file

@ -138,8 +138,8 @@ private fun ColumnScope.ManageAppSection(
} }
if (state.showSecureBackup) { if (state.showSecureBackup) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClick, onClick = onSecureBackupClick,
) )

View file

@ -34,6 +34,7 @@ class AdvancedSettingsPresenterTest {
assertThat(initialState.isDeveloperModeEnabled).isFalse() assertThat(initialState.isDeveloperModeEnabled).isFalse()
assertThat(initialState.showChangeThemeDialog).isFalse() assertThat(initialState.showChangeThemeDialog).isFalse()
assertThat(initialState.isSharePresenceEnabled).isTrue() assertThat(initialState.isSharePresenceEnabled).isTrue()
assertThat(initialState.doesCompressMedia).isFalse()
assertThat(initialState.theme).isEqualTo(Theme.System) assertThat(initialState.theme).isEqualTo(Theme.System)
} }
} }
@ -68,6 +69,21 @@ class AdvancedSettingsPresenterTest {
} }
} }
@Test
fun `present - compress media off on`() = runTest {
val presenter = createAdvancedSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.doesCompressMedia).isFalse()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetCompressMedia(true))
assertThat(awaitItem().doesCompressMedia).isTrue()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetCompressMedia(false))
assertThat(awaitItem().doesCompressMedia).isFalse()
}
}
@Test @Test
fun `present - change theme`() = runTest { fun `present - change theme`() = runTest {
val presenter = createAdvancedSettingsPresenter() val presenter = createAdvancedSettingsPresenter()

View file

@ -8,12 +8,18 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
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.compose.LocalAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
@ -91,16 +97,64 @@ class AdvancedSettingsViewTest {
rule.clickOn(R.string.screen_advanced_settings_share_presence) rule.clickOn(R.string.screen_advanced_settings_share_presence)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true))
} }
@Test
fun `clicking on media to enable compression emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
val analyticsService = FakeAnalyticsService()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
),
analyticsService = analyticsService
)
rule.clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(true))
assertThat(analyticsService.capturedEvents).isEqualTo(
listOf(
Interaction(
name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
)
)
)
}
@Test
fun `clicking on media to disable compression emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
val analyticsService = FakeAnalyticsService()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
doesCompressMedia = true,
eventSink = eventsRecorder,
),
analyticsService = analyticsService
)
rule.clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(false))
assertThat(analyticsService.capturedEvents).isEqualTo(
listOf(
Interaction(
name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
)
)
)
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState, state: AdvancedSettingsState,
analyticsService: AnalyticsService = FakeAnalyticsService(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
AdvancedSettingsView( CompositionLocalProvider(
state = state, LocalAnalyticsService provides analyticsService,
onBackClick = onBackClick, ) {
) AdvancedSettingsView(
state = state,
onBackClick = onBackClick,
)
}
} }
} }

View file

@ -25,6 +25,7 @@ internal fun SetUpRecoveryKeyBanner(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.banner_set_up_recovery_title), title = stringResource(R.string.banner_set_up_recovery_title),
content = stringResource(R.string.banner_set_up_recovery_content), content = stringResource(R.string.banner_set_up_recovery_content),
actionText = stringResource(R.string.banner_set_up_recovery_submit),
onSubmitClick = onContinueClick, onSubmitClick = onContinueClick,
onDismissClick = onDismissClick, onDismissClick = onDismissClick,
) )

View file

@ -4,8 +4,9 @@
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string> <string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string> <string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
<string name="banner_set_up_recovery_content">"Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."</string> <string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_title">"Set up recovery"</string> <string name="banner_set_up_recovery_submit">"Set up recovery"</string>
<string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</string>
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string> <string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string> <string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string>
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string> <string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>

View file

@ -136,7 +136,7 @@ class RoomListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue() assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenNeedsSessionVerification(false) sessionVerificationService.emitNeedsSessionVerification(false)
encryptionService.emitBackupState(BackupState.ENABLED) encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isFalse() assertThat(finalState.showAvatarIndicator).isFalse()
@ -231,7 +231,7 @@ class RoomListPresenterTest {
roomListService = roomListService, roomListService = roomListService,
encryptionService = encryptionService, encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply { sessionVerificationService = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false) emitNeedsSessionVerification(false)
}, },
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)), syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
) )

View file

@ -121,12 +121,9 @@ class RoomListViewTest {
), ),
onSetUpRecoveryClick = callback, onSetUpRecoveryClick = callback,
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(R.string.banner_set_up_recovery_submit)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
} }

View file

@ -22,7 +22,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
@ -63,9 +62,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object Disable : NavTarget data object Disable : NavTarget
@Parcelize
data object Enable : NavTarget
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@ -91,10 +87,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Disable) backstack.push(NavTarget.Disable)
} }
override fun onEnableClick() {
backstack.push(NavTarget.Enable)
}
override fun onConfirmRecoveryKeyClick() { override fun onConfirmRecoveryKeyClick() {
backstack.push(NavTarget.EnterRecoveryKey) backstack.push(NavTarget.EnterRecoveryKey)
} }
@ -116,9 +108,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.Disable -> { NavTarget.Disable -> {
createNode<SecureBackupDisableNode>(buildContext) createNode<SecureBackupDisableNode>(buildContext)
} }
NavTarget.Enable -> {
createNode<SecureBackupEnableNode>(buildContext)
}
NavTarget.EnterRecoveryKey -> { NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback { val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() { override fun onEnterRecoveryKeySuccess() {

View file

@ -37,11 +37,7 @@ class SecureBackupDisablePresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupDisableEvents) { fun handleEvents(event: SecureBackupDisableEvents) {
when (event) { when (event) {
is SecureBackupDisableEvents.DisableBackup -> if (disableAction.value.isConfirming()) { is SecureBackupDisableEvents.DisableBackup -> coroutineScope.disableBackup(disableAction)
coroutineScope.disableBackup(disableAction)
} else {
disableAction.value = AsyncAction.ConfirmingNoParams
}
SecureBackupDisableEvents.DismissDialogs -> { SecureBackupDisableEvents.DismissDialogs -> {
disableAction.value = AsyncAction.Uninitialized disableAction.value = AsyncAction.Uninitialized
} }

View file

@ -7,6 +7,7 @@
package io.element.android.features.securebackup.impl.disable package io.element.android.features.securebackup.impl.disable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@ -25,7 +26,6 @@ import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -44,7 +44,7 @@ fun SecureBackupDisableView(
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = R.string.screen_key_backup_disable_title), title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description), subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()), iconStyle = BigIcon.Style.AlertSolid,
buttons = { Buttons(state = state) }, buttons = { Buttons(state = state) },
) { ) {
Content(state = state) Content(state = state)
@ -52,12 +52,6 @@ fun SecureBackupDisableView(
AsyncActionView( AsyncActionView(
async = state.disableAction, async = state.disableAction,
confirmationDialog = {
SecureBackupDisableConfirmationDialog(
onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) },
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
)
},
progressDialog = {}, progressDialog = {},
errorMessage = { it.message ?: it.toString() }, errorMessage = { it.message ?: it.toString() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) }, onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
@ -65,18 +59,6 @@ fun SecureBackupDisableView(
) )
} }
@Composable
private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_key_backup_disable_confirmation_title),
content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description),
submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off),
destructiveSubmit = true,
onSubmitClick = onConfirm,
onDismiss = onDismiss,
)
}
@Composable @Composable
private fun ColumnScope.Buttons( private fun ColumnScope.Buttons(
state: SecureBackupDisableState, state: SecureBackupDisableState,
@ -105,15 +87,20 @@ private fun Content(state: SecureBackupDisableState) {
@Composable @Composable
private fun SecureBackupDisableItem(text: String) { private fun SecureBackupDisableItem(text: String) {
Row(modifier = Modifier.fillMaxWidth()) { Row(
modifier = Modifier
.fillMaxWidth()
.background(color = ElementTheme.colors.bgActionSecondaryHovered)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = null, contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary, tint = ElementTheme.colors.iconCriticalPrimary,
modifier = Modifier.size(20.dp) modifier = Modifier.size(24.dp)
) )
Text( Text(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
text = text, text = text,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,

View file

@ -1,13 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
sealed interface SecureBackupEnableEvents {
data object EnableBackup : SecureBackupEnableEvents
data object DismissDialog : SecureBackupEnableEvents
}

View file

@ -1,36 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class SecureBackupEnableNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupEnablePresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecureBackupEnableView(
state = state,
modifier = modifier,
onSuccess = ::navigateUp,
onBackClick = ::navigateUp,
)
}
}

View file

@ -1,54 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.securebackup.impl.loggerTagDisable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class SecureBackupEnablePresenter @Inject constructor(
private val encryptionService: EncryptionService,
) : Presenter<SecureBackupEnableState> {
@Composable
override fun present(): SecureBackupEnableState {
val enableAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupEnableEvents) {
when (event) {
is SecureBackupEnableEvents.EnableBackup ->
coroutineScope.enableBackup(enableAction)
SecureBackupEnableEvents.DismissDialog -> {
enableAction.value = AsyncAction.Uninitialized
}
}
}
return SecureBackupEnableState(
enableAction = enableAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.enableBackup(action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
encryptionService.enableBackups().getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View file

@ -1,15 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import io.element.android.libraries.architecture.AsyncAction
data class SecureBackupEnableState(
val enableAction: AsyncAction<Unit>,
val eventSink: (SecureBackupEnableEvents) -> Unit
)

View file

@ -1,28 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class SecureBackupEnableStateProvider : PreviewParameterProvider<SecureBackupEnableState> {
override val values: Sequence<SecureBackupEnableState>
get() = sequenceOf(
aSecureBackupEnableState(),
aSecureBackupEnableState(enableAction = AsyncAction.Loading),
aSecureBackupEnableState(enableAction = AsyncAction.Failure(Exception("Failed to enable"))),
// Add other states here
)
}
fun aSecureBackupEnableState(
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = SecureBackupEnableState(
enableAction = enableAction,
eventSink = {}
)

View file

@ -1,69 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@Composable
fun SecureBackupEnableView(
state: SecureBackupEnableState,
onSuccess: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state = state) }
)
AsyncActionView(
async = state.enableAction,
progressDialog = { },
onSuccess = { onSuccess() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) }
)
}
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupEnableState,
) {
Button(
text = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
showProgress = state.enableAction.isLoading(),
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupEnableEvents.EnableBackup) }
)
}
@PreviewsDayNight
@Composable
internal fun SecureBackupEnableViewPreview(
@PreviewParameter(SecureBackupEnableStateProvider::class) state: SecureBackupEnableState
) = ElementPreview {
SecureBackupEnableView(
state = state,
onSuccess = {},
onBackClick = {},
)
}

View file

@ -9,4 +9,7 @@ package io.element.android.features.securebackup.impl.root
sealed interface SecureBackupRootEvents { sealed interface SecureBackupRootEvents {
data object RetryKeyBackupState : SecureBackupRootEvents data object RetryKeyBackupState : SecureBackupRootEvents
data object EnableKeyStorage : SecureBackupRootEvents
data object DisplayKeyStorageDisabledError : SecureBackupRootEvents
data object DismissDialog : SecureBackupRootEvents
} }

View file

@ -34,7 +34,6 @@ class SecureBackupRootNode @AssistedInject constructor(
fun onSetupClick() fun onSetupClick()
fun onChangeClick() fun onChangeClick()
fun onDisableClick() fun onDisableClick()
fun onEnableClick()
fun onConfirmRecoveryKeyClick() fun onConfirmRecoveryKeyClick()
} }
@ -50,10 +49,6 @@ class SecureBackupRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onDisableClick() } plugins<Callback>().forEach { it.onDisableClick() }
} }
private fun onEnableClick() {
plugins<Callback>().forEach { it.onEnableClick() }
}
private fun onConfirmRecoveryKeyClick() { private fun onConfirmRecoveryKeyClick() {
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() } plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() }
} }
@ -71,7 +66,6 @@ class SecureBackupRootNode @AssistedInject constructor(
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onSetupClick = ::onSetupClick, onSetupClick = ::onSetupClick,
onChangeClick = ::onChangeClick, onChangeClick = ::onChangeClick,
onEnableClick = ::onEnableClick,
onDisableClick = ::onDisableClick, onDisableClick = ::onDisableClick,
onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick,
onLearnMoreClick = { onLearnMoreClick(uriHandler) }, onLearnMoreClick = { onLearnMoreClick(uriHandler) },

View file

@ -15,7 +15,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.securebackup.impl.loggerTagDisable
import io.element.android.features.securebackup.impl.loggerTagRoot import io.element.android.features.securebackup.impl.loggerTagRoot
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -41,7 +44,8 @@ class SecureBackupRootPresenter @Inject constructor(
val backupState by encryptionService.backupStateStateFlow.collectAsState() val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val enableAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var displayKeyStorageDisabledError by remember { mutableStateOf(false) }
Timber.tag(loggerTagRoot.value).d("backupState: $backupState") Timber.tag(loggerTagRoot.value).d("backupState: $backupState")
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState") Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
@ -56,14 +60,22 @@ class SecureBackupRootPresenter @Inject constructor(
fun handleEvents(event: SecureBackupRootEvents) { fun handleEvents(event: SecureBackupRootEvents) {
when (event) { when (event) {
SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction) SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction)
SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction)
SecureBackupRootEvents.DismissDialog -> {
enableAction.value = AsyncAction.Uninitialized
displayKeyStorageDisabledError = false
}
SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true
} }
} }
return SecureBackupRootState( return SecureBackupRootState(
enableAction = enableAction.value,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value, doesBackupExistOnServer = doesBackupExistOnServerAction.value,
recoveryState = recoveryState, recoveryState = recoveryState,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
@ -74,4 +86,11 @@ class SecureBackupRootPresenter @Inject constructor(
encryptionService.doesBackupExistOnServer().getOrThrow() encryptionService.doesBackupExistOnServer().getOrThrow()
}.runCatchingUpdatingState(action) }.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.enableBackup(action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
encryptionService.enableBackups().getOrThrow()
}.runCatchingUpdatingState(action)
}
} }

View file

@ -7,16 +7,31 @@
package io.element.android.features.securebackup.impl.root package io.element.android.features.securebackup.impl.root
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class SecureBackupRootState( data class SecureBackupRootState(
val enableAction: AsyncAction<Unit>,
val backupState: BackupState, val backupState: BackupState,
val doesBackupExistOnServer: AsyncData<Boolean>, val doesBackupExistOnServer: AsyncData<Boolean>,
val recoveryState: RecoveryState, val recoveryState: RecoveryState,
val appName: String, val appName: String,
val displayKeyStorageDisabledError: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val eventSink: (SecureBackupRootEvents) -> Unit, val eventSink: (SecureBackupRootEvents) -> Unit,
) ) {
val isKeyStorageEnabled: Boolean
get() = when (backupState) {
BackupState.UNKNOWN -> doesBackupExistOnServer.dataOrNull() == true
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.DOWNLOADING,
BackupState.ENABLED -> true
BackupState.WAITING_FOR_SYNC,
BackupState.DISABLING -> false
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.securebackup.impl.root package io.element.android.features.securebackup.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
@ -22,28 +23,47 @@ open class SecureBackupRootStateProvider : PreviewParameterProvider<SecureBackup
aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))), aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))),
aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC), aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC),
aSecureBackupRootState(backupState = BackupState.CREATING), aSecureBackupRootState(backupState = BackupState.CREATING),
aSecureBackupRootState(
backupState = BackupState.CREATING,
enableAction = AsyncAction.Failure(Exception("Error")),
),
aSecureBackupRootState(backupState = BackupState.ENABLING),
aSecureBackupRootState(backupState = BackupState.RESUMING), aSecureBackupRootState(backupState = BackupState.RESUMING),
aSecureBackupRootState(backupState = BackupState.DOWNLOADING), aSecureBackupRootState(backupState = BackupState.DOWNLOADING),
aSecureBackupRootState(backupState = BackupState.DISABLING), aSecureBackupRootState(backupState = BackupState.DISABLING),
aSecureBackupRootState(backupState = BackupState.ENABLED), aSecureBackupRootState(backupState = BackupState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.UNKNOWN),
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.DISABLED),
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.INCOMPLETE),
// Add other states here aSecureBackupRootState(
backupState = BackupState.UNKNOWN,
doesBackupExistOnServer = AsyncData.Success(false),
recoveryState = RecoveryState.ENABLED,
),
aSecureBackupRootState(
backupState = BackupState.UNKNOWN,
doesBackupExistOnServer = AsyncData.Success(false),
recoveryState = RecoveryState.ENABLED,
displayKeyStorageDisabledError = true,
),
) )
} }
fun aSecureBackupRootState( fun aSecureBackupRootState(
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
backupState: BackupState = BackupState.UNKNOWN, backupState: BackupState = BackupState.UNKNOWN,
doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized, doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized,
recoveryState: RecoveryState = RecoveryState.UNKNOWN, recoveryState: RecoveryState = RecoveryState.UNKNOWN,
displayKeyStorageDisabledError: Boolean = false,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
) = SecureBackupRootState( ) = SecureBackupRootState(
enableAction = enableAction,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer, doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState, recoveryState = recoveryState,
appName = "Element", appName = "Element",
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = {}, eventSink = {},
) )

View file

@ -7,28 +7,27 @@
package io.element.android.features.securebackup.impl.root package io.element.android.features.securebackup.impl.root
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.layout.fillMaxWidth
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
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncLoading import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
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.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost 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.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
@ -41,7 +40,6 @@ fun SecureBackupRootView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSetupClick: () -> Unit, onSetupClick: () -> Unit,
onChangeClick: () -> Unit, onChangeClick: () -> Unit,
onEnableClick: () -> Unit,
onDisableClick: () -> Unit, onDisableClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
@ -52,122 +50,186 @@ fun SecureBackupRootView(
PreferencePage( PreferencePage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_chat_backup), title = stringResource(id = CommonStrings.common_encryption),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
val text = buildAnnotatedStringWithStyledPart( ListItem(
fullTextRes = R.string.screen_chat_backup_key_backup_description, headlineContent = {
coloredTextRes = CommonStrings.action_learn_more, Text(
color = ElementTheme.colors.textPrimary, text = stringResource(id = R.string.screen_chat_backup_key_backup_title),
underline = false, )
bold = true, },
) supportingContent = {
PreferenceText( Text(
title = stringResource(id = R.string.screen_chat_backup_key_backup_title), text = buildAnnotatedStringWithStyledPart(
subtitleAnnotated = text, fullTextRes = R.string.screen_chat_backup_key_backup_description,
coloredTextRes = CommonStrings.action_learn_more,
color = ElementTheme.colors.textPrimary,
underline = false,
bold = true,
),
)
},
onClick = onLearnMoreClick, onClick = onLearnMoreClick,
) )
// Disable / Enable backup // Disable / Enable key storage
when (state.backupState) { ListItem(
BackupState.WAITING_FOR_SYNC -> Unit headlineContent = {
BackupState.UNKNOWN -> { Text(
when (state.doesBackupExistOnServer) { text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title),
is AsyncData.Success -> when (state.doesBackupExistOnServer.data) { )
true -> { },
PreferenceText( trailingContent = when (state.backupState) {
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), BackupState.WAITING_FOR_SYNC,
tintColor = ElementTheme.colors.textCriticalPrimary, BackupState.DISABLING -> ListItemContent.Custom { LoadingView() }
onClick = onDisableClick, BackupState.UNKNOWN -> {
when (state.doesBackupExistOnServer) {
is AsyncData.Success -> {
ListItemContent.Switch(checked = state.doesBackupExistOnServer.data)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> ListItemContent.Custom { LoadingView() }
is AsyncData.Failure -> ListItemContent.Custom {
Text(
text = stringResource(id = CommonStrings.action_retry)
) )
} }
false -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
onClick = onEnableClick,
)
}
}
is AsyncData.Loading,
AsyncData.Uninitialized -> {
ListItem(headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
})
}
is AsyncData.Failure -> {
ListItem(
headlineContent = {
Text(
text = stringResource(id = CommonStrings.error_unknown),
)
},
trailingContent = ListItemContent.Custom {
TextButton(
text = stringResource(
id = CommonStrings.action_retry
),
onClick = { state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState) }
)
}
)
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
onClick = onEnableClick,
)
} }
} }
} BackupState.CREATING,
BackupState.CREATING, BackupState.ENABLING,
BackupState.ENABLING, BackupState.RESUMING,
BackupState.RESUMING, BackupState.ENABLED,
BackupState.ENABLED, BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true)
BackupState.DOWNLOADING -> { },
PreferenceText( onClick = {
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), when (state.backupState) {
tintColor = ElementTheme.colors.textCriticalPrimary, BackupState.WAITING_FOR_SYNC,
onClick = onDisableClick, BackupState.DISABLING -> Unit
) BackupState.UNKNOWN -> {
} when (state.doesBackupExistOnServer) {
BackupState.DISABLING -> { is AsyncData.Success -> {
AsyncLoading() if (state.doesBackupExistOnServer.data) {
} onDisableClick()
} } else {
state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage)
PreferenceDivider() }
}
is AsyncData.Loading,
AsyncData.Uninitialized -> Unit
is AsyncData.Failure -> state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState)
}
}
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.ENABLED,
BackupState.DOWNLOADING -> onDisableClick()
}
},
)
HorizontalDivider()
// Setup recovery // Setup recovery
when (state.recoveryState) { when (state.recoveryState) {
RecoveryState.UNKNOWN, RecoveryState.UNKNOWN,
RecoveryState.WAITING_FOR_SYNC -> Unit RecoveryState.WAITING_FOR_SYNC -> Unit
RecoveryState.DISABLED -> { RecoveryState.DISABLED -> {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), headlineContent = {
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName), Text(
onClick = onSetupClick, text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
showEndBadge = true, )
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
)
},
trailingContent = ListItemContent.Badge,
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onSetupClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
) )
} }
RecoveryState.ENABLED -> { RecoveryState.ENABLED -> {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_change), headlineContent = {
onClick = onChangeClick, Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
)
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_change_description),
)
},
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onChangeClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
) )
} }
RecoveryState.INCOMPLETE -> RecoveryState.INCOMPLETE ->
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), headlineContent = {
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description), Text(
showEndBadge = true, text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
onClick = onConfirmRecoveryKeyClick, )
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
)
},
trailingContent = ListItemContent.Badge,
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onConfirmRecoveryKeyClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
) )
} }
} }
AsyncActionView(
async = state.enableAction,
progressDialog = { },
onSuccess = { },
onErrorDismiss = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) }
)
if (state.displayKeyStorageDisabledError) {
ErrorDialog(
title = null,
content = stringResource(id = R.string.screen_chat_backup_key_storage_disabled_error),
onSubmit = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) },
)
}
}
@Composable
private fun LoadingView() {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(24.dp),
strokeWidth = 2.dp
)
} }
@PreviewsDayNight @PreviewsDayNight
@ -180,7 +242,6 @@ internal fun SecureBackupRootViewPreview(
onBackClick = {}, onBackClick = {},
onSetupClick = {}, onSetupClick = {},
onChangeClick = {}, onChangeClick = {},
onEnableClick = {},
onDisableClick = {}, onDisableClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onLearnMoreClick = {}, onLearnMoreClick = {},

View file

@ -91,14 +91,14 @@ private fun RecoveryKeyStaticContent(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background( .background(
color = ElementTheme.colors.bgSubtleSecondary, color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) )
.clickableIfNotNull(onClick) .clickableIfNotNull(onClick)
.padding(horizontal = 16.dp, vertical = 16.dp), .padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (state.formattedRecoveryKey != null) { if (state.formattedRecoveryKey != null) {
@ -116,15 +116,15 @@ private fun RecoveryKeyStaticContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 11.dp) .padding(vertical = 11.dp)
) { ) {
if (state.inProgress) { if (state.inProgress) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.progressSemantics() .progressSemantics()
.padding(end = 8.dp) .padding(end = 8.dp)
.size(16.dp), .size(16.dp),
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp, strokeWidth = 1.5.dp,
) )
@ -161,12 +161,12 @@ private fun RecoveryKeyFormContent(
} }
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.recoveryKey) .testTag(TestTags.recoveryKey)
.autofill( .autofill(
autofillTypes = listOf(AutofillType.Password), autofillTypes = listOf(AutofillType.Password),
onFill = { onChange(it) }, onFill = { onChange(it) },
), ),
minLines = 2, minLines = 2,
value = state.formattedRecoveryKey.orEmpty(), value = state.formattedRecoveryKey.orEmpty(),
onValueChange = onChange, onValueChange = onChange,
@ -189,30 +189,18 @@ private fun RecoveryKeyFooter(state: RecoveryKeyViewState) {
RecoveryKeyUserStory.Setup, RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> { RecoveryKeyUserStory.Change -> {
if (state.formattedRecoveryKey == null) { if (state.formattedRecoveryKey == null) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = stringResource(
) { id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
Icon( R.string.screen_recovery_key_change_generate_key_description
imageVector = CompoundIcons.InfoSolid(), } else {
contentDescription = null, R.string.screen_recovery_key_setup_generate_key_description
tint = ElementTheme.colors.iconSecondary, }
modifier = Modifier ),
.padding(start = 16.dp) color = ElementTheme.colors.textSecondary,
.size(20.dp), modifier = Modifier.padding(start = 16.dp),
) style = ElementTheme.typography.fontBodySmRegular,
Text( )
text = stringResource(
id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
R.string.screen_recovery_key_change_generate_key_description
} else {
R.string.screen_recovery_key_setup_generate_key_description
}
),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 8.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
}
} else { } else {
Text( Text(
text = stringResource(id = R.string.screen_recovery_key_save_key_description), text = stringResource(id = R.string.screen_recovery_key_save_key_description),

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string> <string name="screen_chat_backup_key_backup_action_disable">"Delete key storage"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string> <string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
<string name="screen_chat_backup_key_backup_description">"Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$s."</string> <string name="screen_chat_backup_key_backup_description">"Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Key storage"</string> <string name="screen_chat_backup_key_backup_title">"Key storage"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Key storage must be turned on to set up recovery."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Upload keys from this device"</string> <string name="screen_chat_backup_key_storage_toggle_description">"Upload keys from this device"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Allow key storage"</string> <string name="screen_chat_backup_key_storage_toggle_title">"Allow key storage"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string> <string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
@ -28,10 +29,10 @@
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string> <string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string> <string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string> <string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string> <string name="screen_key_backup_disable_description">"Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:"</string>
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string> <string name="screen_key_backup_disable_description_point_1">"You will not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</string> <string name="screen_key_backup_disable_description_point_2">"You will lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string> <string name="screen_key_backup_disable_title">"Are you sure you want to turn off key storage and delete it?"</string>
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string> <string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string> <string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Do not share this with anyone!"</string> <string name="screen_recovery_key_change_generate_key_description">"Do not share this with anyone!"</string>
@ -54,7 +55,7 @@
<string name="screen_recovery_key_save_title">"Save your recovery key somewhere safe"</string> <string name="screen_recovery_key_save_title">"Save your recovery key somewhere safe"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string> <string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string> <string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting Change recovery key."</string> <string name="screen_recovery_key_setup_description">"Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string> <string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Do not share this with anyone!"</string> <string name="screen_recovery_key_setup_generate_key_description">"Do not share this with anyone!"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string> <string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>

View file

@ -38,22 +38,6 @@ class SecureBackupDisablePresenterTest {
} }
} }
@Test
fun `present - user delete backup and cancel`() = runTest {
val presenter = createSecureBackupDisablePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DismissDialogs)
val finalState = awaitItem()
assertThat(finalState.disableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test @Test
fun `present - user delete backup success`() = runTest { fun `present - user delete backup success`() = runTest {
val presenter = createSecureBackupDisablePresenter() val presenter = createSecureBackupDisablePresenter()
@ -63,9 +47,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup) initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem() val finalState = awaitItem()
@ -87,9 +68,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup) initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() val errorState = awaitItem()

View file

@ -1,78 +0,0 @@
/*
* Copyright 2023, 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.securebackup.impl.enable
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SecureBackupEnablePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - user enable backup`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
val loadingState = awaitItem()
assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.enableAction).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - user enable backup with error`() = runTest {
val encryptionService = FakeEncryptionService()
encryptionService.givenEnableBackupsFailure(AN_EXCEPTION)
val presenter = createPresenter(encryptionService = encryptionService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
val loadingState = awaitItem()
assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.enableAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink(SecureBackupEnableEvents.DismissDialog)
val finalState = awaitItem()
assertThat(finalState.enableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
) = SecureBackupEnablePresenter(
encryptionService = encryptionService,
)
}

View file

@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
@ -38,6 +39,8 @@ class SecureBackupRootPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer.dataOrNull()).isTrue() assertThat(initialState.doesBackupExistOnServer.dataOrNull()).isTrue()
assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.displayKeyStorageDisabledError).isFalse()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
assertThat(initialState.appName).isEqualTo("Element") assertThat(initialState.appName).isEqualTo("Element")
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
@ -70,6 +73,35 @@ class SecureBackupRootPresenterTest {
} }
} }
@Test
fun `present - setting up encryption when key storage is disabled should emit a state to render a dialog`() = runTest {
val presenter = createSecureBackupRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
assertThat(awaitItem().displayKeyStorageDisabledError).isTrue()
initialState.eventSink(SecureBackupRootEvents.DismissDialog)
assertThat(awaitItem().displayKeyStorageDisabledError).isFalse()
}
}
@Test
fun `present - enable key storage invoke the expected API`() = runTest {
val presenter = createSecureBackupRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(SecureBackupRootEvents.EnableKeyStorage)
assertThat(awaitItem().enableAction.isLoading()).isTrue()
assertThat(awaitItem().enableAction.isSuccess()).isTrue()
}
}
private fun createSecureBackupRootPresenter( private fun createSecureBackupRootPresenter(
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
appName: String = "Element", appName: String = "Element",

View file

@ -49,6 +49,7 @@ dependencies {
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -31,6 +32,7 @@ class SharePresenter @AssistedInject constructor(
private val shareIntentHandler: ShareIntentHandler, private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -71,13 +73,16 @@ class SharePresenter @AssistedInject constructor(
roomIds roomIds
.map { roomId -> .map { roomId ->
val room = matrixClient.getRoom(roomId) ?: return@map false val room = matrixClient.getRoom(roomId) ?: return@map false
val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room) val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
filesToShare filesToShare
.map { fileToShare -> .map { fileToShare ->
mediaSender.sendMedia( mediaSender.sendMedia(
uri = fileToShare.uri, uri = fileToShare.uri,
mimeType = fileToShare.mimeType, mimeType = fileToShare.mimeType,
compressIfPossible = true,
).isSuccess ).isSuccess
} }
.all { it } .all { it }

View file

@ -16,6 +16,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -23,13 +25,16 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class SharePresenterTest { class SharePresenterTest {
@ -111,8 +116,11 @@ class SharePresenterTest {
@Test @Test
fun `present - send media ok`() = runTest { fun `present - send media ok`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
sendMediaResult = { Result.success(FakeMediaUploadHandler()) }, sendFileResult = sendFileResult,
) )
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, matrixRoom) givenGetRoomResult(A_ROOM_ID, matrixRoom)
@ -140,6 +148,7 @@ class SharePresenterTest {
val success = awaitItem() val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue() assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendFileResult.assertions().isCalledOnce()
} }
} }
@ -154,7 +163,8 @@ class SharePresenterTest {
appCoroutineScope = this, appCoroutineScope = this,
shareIntentHandler = shareIntentHandler, shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient, matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor mediaPreProcessor = mediaPreProcessor,
InMemorySessionPreferencesStore(),
) )
} }
} }

View file

@ -106,7 +106,7 @@ fun UserProfileView(
private fun VerifyUserSection(state: UserProfileState) { private fun VerifyUserSection(state: UserProfileState) {
if (state.isVerified.dataOrNull() == false) { if (state.isVerified.dataOrNull() == false) {
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_title, state.userName ?: state.userId)) }, headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) },
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) }, supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
enabled = false, enabled = false,

View file

@ -15,4 +15,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
} }

View file

@ -0,0 +1,33 @@
/*
* 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.verifysession.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
data class Params(
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onDone()
}
}

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
@ -43,6 +44,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -1,95 +0,0 @@
/*
* Copyright 2023, 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.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Canceled
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Ready
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Loading
),
aVerifySelfSessionState(
verificationFlowStep = VerificationStep.Skipped
),
// Add other state here
)
}
internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
}
private fun aDecimalsSessionVerificationData(
decimals: List<Int> = listOf(123, 456, 789),
): SessionVerificationData {
return SessionVerificationData.Decimals(decimals)
}
internal fun aVerifySelfSessionState(
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)
private fun aVerificationEmojiList() = listOf(
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
)

View file

@ -0,0 +1,40 @@
/*
* 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.verifysession.impl.incoming
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : IncomingVerificationEntryPoint.NodeBuilder {
override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node {
return parentNode.createNode<IncomingVerificationNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,12 @@
/*
* 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.verifysession.impl.incoming
fun interface IncomingVerificationNavigator {
fun onFinish()
}

View file

@ -0,0 +1,47 @@
/*
* 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.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class IncomingVerificationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: IncomingVerificationPresenter.Factory,
) : Node(buildContext, plugins = plugins),
IncomingVerificationNavigator {
private val presenter = presenterFactory.create(
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
navigator = this,
)
override fun onFinish() {
plugins<IncomingVerificationEntryPoint.Callback>().forEach { it.onDone() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
IncomingVerificationView(
state = state,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,189 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
class IncomingVerificationPresenter @AssistedInject constructor(
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
@Assisted private val navigator: IncomingVerificationNavigator,
private val sessionVerificationService: SessionVerificationService,
private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: LastMessageTimestampFormatter,
) : Presenter<IncomingVerificationState> {
@AssistedFactory
interface Factory {
fun create(
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
navigator: IncomingVerificationNavigator,
): IncomingVerificationPresenter
}
@Composable
override fun present(): IncomingVerificationState {
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset(
cancelAnyPendingVerificationAttempt = false
)
// Acknowledge the request right now
sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails)
}
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val formattedSignInTime = remember {
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
}
val step by remember {
derivedStateOf {
stateAndDispatch.state.value.toVerificationStep(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
formattedSignInTime = formattedSignInTime,
)
}
}
LaunchedEffect(stateAndDispatch.state.value) {
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) {
// The verification was canceled before it was started, maybe because another session accepted it
navigator.onFinish()
}
}
// Start this after observing state machine
LaunchedEffect(Unit) {
observeVerificationService()
}
fun handleEvents(event: IncomingVerificationViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
IncomingVerificationViewEvents.StartVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest)
IncomingVerificationViewEvents.IgnoreVerification ->
navigator.onFinish()
IncomingVerificationViewEvents.ConfirmVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
IncomingVerificationViewEvents.DeclineVerification ->
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
IncomingVerificationViewEvents.GoBack -> {
when (val verificationStep = step) {
is Step.Initial -> if (verificationStep.isWaiting) {
stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
} else {
navigator.onFinish()
}
is Step.Verifying -> if (verificationStep.isWaiting) {
// What do we do in this case?
} else {
stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
}
Step.Canceled,
Step.Completed,
Step.Failure -> navigator.onFinish()
}
}
}
}
return IncomingVerificationState(
step = step,
eventSink = ::handleEvents,
)
}
private fun StateMachineState?.toVerificationStep(
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
formattedSignInTime: String,
): Step =
when (val machineState = this) {
is StateMachineState.Initial,
IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
IncomingVerificationStateMachine.State.RejectingIncomingVerification,
null -> {
Step.Initial(
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime,
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
)
}
is IncomingVerificationStateMachine.State.ChallengeReceived ->
Step.Verifying(
data = machineState.data,
isWaiting = false,
)
IncomingVerificationStateMachine.State.Completed -> Step.Completed
IncomingVerificationStateMachine.State.Canceling,
IncomingVerificationStateMachine.State.Failure -> Step.Failure
is IncomingVerificationStateMachine.State.AcceptingChallenge ->
Step.Verifying(
data = machineState.data,
isWaiting = true,
)
is IncomingVerificationStateMachine.State.RejectingChallenge ->
Step.Verifying(
data = machineState.data,
isWaiting = true,
)
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
}
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial,
VerificationFlowState.DidAcceptVerificationRequest,
VerificationFlowState.DidStartSasVerification -> Unit
is VerificationFlowState.DidReceiveVerificationData -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
}
VerificationFlowState.DidFinish -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
}
VerificationFlowState.DidCancel -> {
// Can happen when:
// - the remote party cancel the verification (before it is started)
// - another session has accepted the incoming verification request
// - the user reject the challenge from this application (I think this is an error). In this case, the state
// machine will ignore this event and change state to Failure.
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
}
VerificationFlowState.DidFail -> {
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail)
}
}
}
.launchIn(this)
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.verifysession.impl.incoming
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@Immutable
data class IncomingVerificationState(
val step: Step,
val eventSink: (IncomingVerificationViewEvents) -> Unit,
) {
@Stable
sealed interface Step {
data class Initial(
val deviceDisplayName: String,
val deviceId: DeviceId,
val formattedSignInTime: String,
val isWaiting: Boolean,
) : Step
data class Verifying(
val data: SessionVerificationData,
val isWaiting: Boolean,
) : Step
data object Canceled : Step
data object Completed : Step
data object Failure : Step
}
}

View file

@ -0,0 +1,158 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl.incoming
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.features.verifysession.impl.util.andLogStateChange
import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import javax.inject.Inject
import com.freeletics.flowredux.dsl.State as MachineState
class IncomingVerificationStateMachine @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
) : FlowReduxStateMachine<IncomingVerificationStateMachine.State, IncomingVerificationStateMachine.Event>(
initialState = State.Initial(isCancelled = false)
) {
init {
spec {
inState<State.Initial> {
on { _: Event.AcceptIncomingRequest, state ->
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
}
}
inState<State.AcceptingIncomingVerification> {
onEnterEffect {
sessionVerificationService.acceptVerificationRequest()
}
on { event: Event.DidReceiveChallenge, state ->
state.override { State.ChallengeReceived(event.data).andLogStateChange() }
}
}
inState<State.ChallengeReceived> {
on { _: Event.AcceptChallenge, state ->
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
}
on { _: Event.DeclineChallenge, state ->
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
}
}
inState<State.AcceptingChallenge> {
onEnterEffect { _ ->
sessionVerificationService.approveVerification()
}
on { _: Event.DidAcceptChallenge, state ->
state.override { State.Completed.andLogStateChange() }
}
}
inState<State.RejectingChallenge> {
onEnterEffect { _ ->
sessionVerificationService.declineVerification()
}
}
inState<State.Canceling> {
onEnterEffect {
sessionVerificationService.cancelVerification()
}
}
inState {
logReceivedEvents()
on { _: Event.Cancel, state: MachineState<State> ->
when (state.snapshot) {
State.Completed, State.Canceled -> state.noChange()
else -> {
sessionVerificationService.cancelVerification()
state.override { State.Canceled.andLogStateChange() }
}
}
}
on { _: Event.DidCancel, state: MachineState<State> ->
when (state.snapshot) {
is State.RejectingChallenge -> {
state.override { State.Failure.andLogStateChange() }
}
is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() }
State.AcceptingIncomingVerification,
State.RejectingIncomingVerification,
is State.ChallengeReceived,
is State.AcceptingChallenge,
State.Canceling -> state.override { State.Canceled.andLogStateChange() }
State.Canceled,
State.Completed,
State.Failure -> state.noChange()
}
}
on { _: Event.DidFail, state: MachineState<State> ->
state.override { State.Failure.andLogStateChange() }
}
}
}
}
sealed interface State {
/** The initial state, before verification started. */
data class Initial(val isCancelled: Boolean) : State
/** User is accepting the incoming verification. */
data object AcceptingIncomingVerification : State
/** User is rejecting the incoming verification. */
data object RejectingIncomingVerification : State
/** Verification accepted and emojis received. */
data class ChallengeReceived(val data: SessionVerificationData) : State
/** Accepting the verification challenge. */
data class AcceptingChallenge(val data: SessionVerificationData) : State
/** Rejecting the verification challenge. */
data class RejectingChallenge(val data: SessionVerificationData) : State
/** The verification is being canceled. */
data object Canceling : State
/** The verification has been canceled, remotely or locally. */
data object Canceled : State
/** Verification successful. */
data object Completed : State
/** Verification failure. */
data object Failure : State
}
sealed interface Event {
/** User accepts the incoming request. */
data object AcceptIncomingRequest : Event
/** Has received data. */
data class DidReceiveChallenge(val data: SessionVerificationData) : Event
/** Emojis match. */
data object AcceptChallenge : Event
/** Emojis do not match. */
data object DeclineChallenge : Event
/** Remote accepted challenge. */
data object DidAcceptChallenge : Event
/** Request cancellation. */
data object Cancel : Event
/** Verification cancelled. */
data object DidCancel : Event
/** Request failed. */
data object DidFail : Event
}
}

View file

@ -0,0 +1,46 @@
/*
* 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.verifysession.impl.incoming
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.matrix.api.core.DeviceId
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
override val values: Sequence<IncomingVerificationState>
get() = sequenceOf(
anIncomingVerificationState(),
anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Completed),
anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled),
// Add other state here
)
}
internal fun aStepInitial(
isWaiting: Boolean = false,
) = Step.Initial(
deviceDisplayName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
formattedSignInTime = "12:34",
isWaiting = isWaiting,
)
internal fun anIncomingVerificationState(
step: Step = aStepInitial(),
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
) = IncomingVerificationState(
step = step,
eventSink = eventSink,
)

View file

@ -0,0 +1,235 @@
/*
* 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.verifysession.impl.incoming
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
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.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.ui.strings.CommonStrings
/**
* [Figma](https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=819-7324).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IncomingVerificationView(
state: IncomingVerificationState,
modifier: Modifier = Modifier,
) {
val step = state.step
BackHandler {
state.eventSink(IncomingVerificationViewEvents.GoBack)
}
HeaderFooterPage(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
)
},
header = {
IncomingVerificationHeader(step = step)
},
footer = {
IncomingVerificationBottomMenu(
state = state,
)
}
) {
IncomingVerificationContent(
step = step,
)
}
}
@Composable
private fun IncomingVerificationHeader(step: Step) {
val iconStyle = when (step) {
Step.Canceled,
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
Step.Completed -> BigIcon.Style.SuccessSolid
Step.Failure -> BigIcon.Style.AlertSolid
}
val titleTextId = when (step) {
Step.Canceled -> CommonStrings.common_verification_cancelled
is Step.Initial -> R.string.screen_session_verification_request_title
is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
Step.Completed -> R.string.screen_session_verification_request_success_title
Step.Failure -> R.string.screen_session_verification_request_failure_title
}
val subtitleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
is Step.Initial -> R.string.screen_session_verification_request_subtitle
is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
}
Step.Completed -> R.string.screen_session_verification_request_success_subtitle
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
}
PageTitle(
iconStyle = iconStyle,
title = stringResource(id = titleTextId),
subtitle = stringResource(id = subtitleTextId)
)
}
@Composable
private fun IncomingVerificationContent(
step: Step,
) {
when (step) {
is Step.Initial -> ContentInitial(step)
is Step.Verifying -> VerificationContentVerifying(step.data)
else -> Unit
}
}
@Composable
private fun ContentInitial(
initialIncoming: Step.Initial,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SessionDetailsView(
deviceName = initialIncoming.deviceDisplayName,
deviceId = initialIncoming.deviceId,
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
)
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp),
text = stringResource(R.string.screen_session_verification_request_footer),
style = ElementTheme.typography.fontBodyMdMedium,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun IncomingVerificationBottomMenu(
state: IncomingVerificationState,
) {
val step = state.step
val eventSink = state.eventSink
when (step) {
is Step.Initial -> {
if (step.isWaiting) {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
enabled = false,
showProgress = true,
)
// Placeholder so the 1st button keeps its vertical position
Spacer(modifier = Modifier.height(40.dp))
}
} else {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start),
onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ignore),
onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) },
)
}
}
}
is Step.Verifying -> {
if (step.isWaiting) {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
onClick = {},
enabled = false,
showProgress = true,
)
// Placeholder so the 1st button keeps its vertical position
Spacer(modifier = Modifier.height(40.dp))
}
} else {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) },
)
}
}
}
Step.Canceled,
is Step.Completed,
is Step.Failure -> {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_done),
onClick = { eventSink(IncomingVerificationViewEvents.GoBack) },
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificationStateProvider::class) state: IncomingVerificationState) = ElementPreview {
IncomingVerificationView(
state = state,
)
}

View file

@ -0,0 +1,16 @@
/*
* 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.verifysession.impl.incoming
sealed interface IncomingVerificationViewEvents {
data object GoBack : IncomingVerificationViewEvents
data object StartVerification : IncomingVerificationViewEvents
data object IgnoreVerification : IncomingVerificationViewEvents
data object ConfirmVerification : IncomingVerificationViewEvents
data object DeclineVerification : IncomingVerificationViewEvents
}

View file

@ -0,0 +1,92 @@
/*
* 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.verifysession.impl.incoming.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SessionDetailsView(
deviceName: String,
deviceId: DeviceId,
signInFormattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = ElementTheme.colors.borderDisabled,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RoundedIconAtom(
modifier = Modifier,
size = RoundedIconAtomSize.Big,
resourceId = CompoundDrawables.ic_compound_devices
)
Text(
text = deviceName,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
text = signInFormattedTimestamp,
modifier = Modifier.weight(2f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = deviceId.value,
modifier = Modifier.weight(5f),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SessionDetailsViewPreview() = ElementPreview {
SessionDetailsView(
deviceName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import android.app.Activity import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class) @file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent import timber.log.Timber
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor( class VerifySelfSessionPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean, @Assisted private val showDeviceVerifiedScreen: Boolean,
@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state // Force reset, just in case the service was left in a broken state
sessionVerificationService.reset() sessionVerificationService.reset(true)
} }
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch() val stateAndDispatch = stateMachine.rememberStateAndDispatch()
@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val signOutAction = remember { val signOutAction = remember {
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
} }
val verificationFlowStep by remember { val step by remember {
derivedStateOf { derivedStateOf {
if (skipVerification) { if (skipVerification) {
VerifySelfSessionState.VerificationStep.Skipped VerifySelfSessionState.Step.Skipped
} else { } else {
when (sessionVerifiedStatus) { when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.NotVerified -> { SessionVerifiedStatus.NotVerified -> {
stateAndDispatch.state.value.toVerificationStep( stateAndDispatch.state.value.toVerificationStep(
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
SessionVerifiedStatus.Verified -> { SessionVerifiedStatus.Verified -> {
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) { if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
// The user has verified the session, we need to show the success screen // The user has verified the session, we need to show the success screen
VerifySelfSessionState.VerificationStep.Completed VerifySelfSessionState.Step.Completed
} else { } else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen // Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.VerificationStep.Skipped VerifySelfSessionState.Step.Skipped
} }
} }
} }
@ -101,6 +102,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
} }
fun handleEvents(event: VerifySelfSessionViewEvents) { fun handleEvents(event: VerifySelfSessionViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) { when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification) VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification) VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
@ -115,7 +117,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
} }
} }
return VerifySelfSessionState( return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep, step = step,
signOutAction = signOutAction.value, signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable, displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents, eventSink = ::handleEvents,
@ -124,10 +126,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
private fun StateMachineState?.toVerificationStep( private fun StateMachineState?.toVerificationStep(
canEnterRecoveryKey: Boolean canEnterRecoveryKey: Boolean
): VerifySelfSessionState.VerificationStep = ): VerifySelfSessionState.Step =
when (val machineState = this) { when (val machineState = this) {
StateMachineState.Initial, null -> { StateMachineState.Initial, null -> {
VerifySelfSessionState.VerificationStep.Initial( VerifySelfSessionState.Step.Initial(
canEnterRecoveryKey = canEnterRecoveryKey, canEnterRecoveryKey = canEnterRecoveryKey,
isLastDevice = encryptionService.isLastDevice.value isLastDevice = encryptionService.isLastDevice.value
) )
@ -136,15 +138,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
StateMachineState.StartingSasVerification, StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted, StateMachineState.SasVerificationStarted,
StateMachineState.Canceling -> { StateMachineState.Canceling -> {
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
} }
StateMachineState.VerificationRequestAccepted -> { StateMachineState.VerificationRequestAccepted -> {
VerifySelfSessionState.VerificationStep.Ready VerifySelfSessionState.Step.Ready
} }
StateMachineState.Canceled -> { StateMachineState.Canceled -> {
VerifySelfSessionState.VerificationStep.Canceled VerifySelfSessionState.Step.Canceled
} }
is StateMachineState.Verifying -> { is StateMachineState.Verifying -> {
@ -152,38 +154,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
is StateMachineState.Verifying.Replying -> AsyncData.Loading() is StateMachineState.Verifying.Replying -> AsyncData.Loading()
else -> AsyncData.Uninitialized else -> AsyncData.Uninitialized
} }
VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async) VerifySelfSessionState.Step.Verifying(machineState.data, async)
} }
StateMachineState.Completed -> { StateMachineState.Completed -> {
VerifySelfSessionState.VerificationStep.Completed VerifySelfSessionState.Step.Completed
} }
} }
private fun CoroutineScope.observeVerificationService() { private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState -> sessionVerificationService.verificationFlowState
when (verificationAttemptState) { .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset) .onEach { verificationAttemptState ->
VerificationFlowState.AcceptedVerificationRequest -> { when (verificationAttemptState) {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
} VerificationFlowState.DidAcceptVerificationRequest -> {
VerificationFlowState.StartedSasVerification -> { stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification) }
} VerificationFlowState.DidStartSasVerification -> {
is VerificationFlowState.ReceivedVerificationData -> { stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data)) }
} is VerificationFlowState.DidReceiveVerificationData -> {
VerificationFlowState.Finished -> { stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge) }
} VerificationFlowState.DidFinish -> {
VerificationFlowState.Canceled -> { stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel) }
} VerificationFlowState.DidCancel -> {
VerificationFlowState.Failed -> { stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail) }
VerificationFlowState.DidFail -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
}
} }
} }
}.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch { private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@ -15,22 +15,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
@Immutable @Immutable
data class VerifySelfSessionState( data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep, val step: Step,
val signOutAction: AsyncAction<String?>, val signOutAction: AsyncAction<String?>,
val displaySkipButton: Boolean, val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit, val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) { ) {
@Stable @Stable
sealed interface VerificationStep { sealed interface Step {
data object Loading : VerificationStep data object Loading : Step
// FIXME canEnterRecoveryKey value is never read. // FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
data object Canceled : VerificationStep data object Canceled : Step
data object AwaitingOtherDeviceResponse : VerificationStep data object AwaitingOtherDeviceResponse : Step
data object Ready : VerificationStep data object Ready : Step
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
data object Completed : VerificationStep data object Completed : Step
data object Skipped : VerificationStep data object Skipped : Step
} }
} }

View file

@ -8,9 +8,11 @@
@file:Suppress("WildcardImport") @file:Suppress("WildcardImport")
@file:OptIn(ExperimentalCoroutinesApi::class) @file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import com.freeletics.flowredux.dsl.FlowReduxStateMachine import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.features.verifysession.impl.util.andLogStateChange
import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -37,10 +39,10 @@ class VerifySelfSessionStateMachine @Inject constructor(
spec { spec {
inState<State.Initial> { inState<State.Initial> {
on { _: Event.RequestVerification, state -> on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification } state.override { State.RequestingVerification.andLogStateChange() }
} }
on { _: Event.StartSasVerification, state -> on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification } state.override { State.StartingSasVerification.andLogStateChange() }
} }
} }
inState<State.RequestingVerification> { inState<State.RequestingVerification> {
@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.requestVerification() sessionVerificationService.requestVerification()
} }
on { _: Event.DidAcceptVerificationRequest, state -> on { _: Event.DidAcceptVerificationRequest, state ->
state.override { State.VerificationRequestAccepted } state.override { State.VerificationRequestAccepted.andLogStateChange() }
} }
} }
inState<State.StartingSasVerification> { inState<State.StartingSasVerification> {
@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
inState<State.VerificationRequestAccepted> { inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state -> on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification } state.override { State.StartingSasVerification.andLogStateChange() }
} }
} }
inState<State.Canceled> { inState<State.Canceled> {
on { _: Event.RequestVerification, state -> on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification } state.override { State.RequestingVerification.andLogStateChange() }
} }
on { _: Event.Reset, state -> on { _: Event.Reset, state ->
state.override { State.Initial } state.override { State.Initial.andLogStateChange() }
} }
} }
inState<State.SasVerificationStarted> { inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state -> on { event: Event.DidReceiveChallenge, state ->
state.override { State.Verifying.ChallengeReceived(event.data) } state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
} }
} }
inState<State.Verifying.ChallengeReceived> { inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state -> on { _: Event.AcceptChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) } state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
} }
on { _: Event.DeclineChallenge, state -> on { _: Event.DeclineChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) } state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
} }
} }
inState<State.Verifying.Replying> { inState<State.Verifying.Replying> {
@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
.first() .first()
} }
} }
state.override { State.Completed } state.override { State.Completed.andLogStateChange() }
} }
} }
inState<State.Canceling> { inState<State.Canceling> {
@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
} }
inState { inState {
logReceivedEvents()
on { _: Event.DidStartSasVerification, state: MachineState<State> -> on { _: Event.DidStartSasVerification, state: MachineState<State> ->
state.override { State.SasVerificationStarted } state.override { State.SasVerificationStarted.andLogStateChange() }
} }
on { _: Event.Cancel, state: MachineState<State> -> on { _: Event.Cancel, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
@ -120,17 +123,17 @@ class VerifySelfSessionStateMachine @Inject constructor(
// `Canceling` state to `Canceled` automatically anymore // `Canceling` state to `Canceled` automatically anymore
else -> { else -> {
sessionVerificationService.cancelVerification() sessionVerificationService.cancelVerification()
state.override { State.Canceled } state.override { State.Canceled.andLogStateChange() }
} }
} }
} }
on { _: Event.DidCancel, state: MachineState<State> -> on { _: Event.DidCancel, state: MachineState<State> ->
state.override { State.Canceled } state.override { State.Canceled.andLogStateChange() }
} }
on { _: Event.DidFail, state: MachineState<State> -> on { _: Event.DidFail, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
is State.RequestingVerification -> state.override { State.Initial } is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
else -> state.override { State.Canceled } else -> state.override { State.Canceled.andLogStateChange() }
} }
} }
} }

View file

@ -0,0 +1,73 @@
/*
* Copyright 2023, 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.verifysession.impl.outgoing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
step = Step.Canceled
),
aVerifySelfSessionState(
step = Step.Ready
),
aVerifySelfSessionState(
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
),
aVerifySelfSessionState(
step = Step.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
aVerifySelfSessionState(
step = Step.Loading
),
aVerifySelfSessionState(
step = Step.Skipped
),
// Add other state here
)
}
internal fun aVerifySelfSessionState(
step: Step = Step.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
step = step,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)

View file

@ -5,25 +5,19 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -31,18 +25,17 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
@ -56,9 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -71,12 +62,13 @@ fun VerifySelfSessionView(
onSuccessLogout: (String?) -> Unit, onSuccessLogout: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val step = state.step
fun cancelOrResetFlow() { fun cancelOrResetFlow() {
when (state.verificationFlowStep) { when (step) {
is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset) is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel) is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is FlowStep.Verifying -> { is Step.Verifying -> {
if (!state.verificationFlowStep.state.isLoading()) { if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
} }
} }
@ -85,18 +77,17 @@ fun VerifySelfSessionView(
} }
val latestOnFinish by rememberUpdatedState(newValue = onFinish) val latestOnFinish by rememberUpdatedState(newValue = onFinish)
LaunchedEffect(state.verificationFlowStep, latestOnFinish) { LaunchedEffect(step, latestOnFinish) {
if (state.verificationFlowStep is FlowStep.Skipped) { if (step is Step.Skipped) {
latestOnFinish() latestOnFinish()
} }
} }
BackHandler { BackHandler {
cancelOrResetFlow() cancelOrResetFlow()
} }
val verificationFlowStep = state.verificationFlowStep
if (state.verificationFlowStep is FlowStep.Loading || if (step is Step.Loading ||
state.verificationFlowStep is FlowStep.Skipped) { step is Step.Skipped) {
// Just display a loader in this case, to avoid UI glitch. // Just display a loader in this case, to avoid UI glitch.
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -111,7 +102,7 @@ fun VerifySelfSessionView(
TopAppBar( TopAppBar(
title = {}, title = {},
actions = { actions = {
if (state.verificationFlowStep !is FlowStep.Completed && if (step !is Step.Completed &&
state.displaySkipButton && state.displaySkipButton &&
LocalInspectionMode.current.not()) { LocalInspectionMode.current.not()) {
TextButton( TextButton(
@ -119,7 +110,7 @@ fun VerifySelfSessionView(
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) } onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
) )
} }
if (state.verificationFlowStep is FlowStep.Initial) { if (step is Step.Initial) {
TextButton( TextButton(
text = stringResource(CommonStrings.action_signout), text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) } onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
@ -129,10 +120,10 @@ fun VerifySelfSessionView(
) )
}, },
header = { header = {
HeaderContent(verificationFlowStep = verificationFlowStep) VerifySelfSessionHeader(step = step)
}, },
footer = { footer = {
BottomMenu( VerifySelfSessionBottomMenu(
screenState = state, screenState = state,
onCancelClick = ::cancelOrResetFlow, onCancelClick = ::cancelOrResetFlow,
onEnterRecoveryKey = onEnterRecoveryKey, onEnterRecoveryKey = onEnterRecoveryKey,
@ -141,8 +132,8 @@ fun VerifySelfSessionView(
) )
} }
) { ) {
Content( VerifySelfSessionContent(
flowState = verificationFlowStep, flowState = step,
onLearnMoreClick = onLearnMoreClick, onLearnMoreClick = onLearnMoreClick,
) )
} }
@ -165,38 +156,38 @@ fun VerifySelfSessionView(
} }
@Composable @Composable
private fun HeaderContent(verificationFlowStep: FlowStep) { private fun VerifySelfSessionHeader(step: Step) {
val iconStyle = when (verificationFlowStep) { val iconStyle = when (step) {
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid()) is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
FlowStep.Canceled -> BigIcon.Style.AlertSolid Step.Canceled -> BigIcon.Style.AlertSolid
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
FlowStep.Completed -> BigIcon.Style.SuccessSolid Step.Completed -> BigIcon.Style.SuccessSolid
is FlowStep.Skipped -> return is Step.Skipped -> return
} }
val titleTextId = when (verificationFlowStep) { val titleTextId = when (step) {
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
FlowStep.Canceled -> CommonStrings.common_verification_cancelled Step.Canceled -> CommonStrings.common_verification_cancelled
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title Step.Ready -> R.string.screen_session_verification_compare_emojis_title
FlowStep.Completed -> R.string.screen_identity_confirmed_title Step.Completed -> R.string.screen_identity_confirmed_title
is FlowStep.Verifying -> when (verificationFlowStep.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
} }
is FlowStep.Skipped -> return is Step.Skipped -> return
} }
val subtitleTextId = when (verificationFlowStep) { val subtitleTextId = when (step) {
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle Step.Ready -> R.string.screen_session_verification_ready_subtitle
FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle Step.Completed -> R.string.screen_identity_confirmed_subtitle
is FlowStep.Verifying -> when (verificationFlowStep.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
} }
is FlowStep.Skipped -> return is Step.Skipped -> return
} }
PageTitle( PageTitle(
@ -207,16 +198,16 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
} }
@Composable @Composable
private fun Content( private fun VerifySelfSessionContent(
flowState: FlowStep, flowState: Step,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
) { ) {
when (flowState) { when (flowState) {
is VerifySelfSessionState.VerificationStep.Initial -> { is Step.Initial -> {
ContentInitial(onLearnMoreClick) ContentInitial(onLearnMoreClick)
} }
is FlowStep.Verifying -> { is Step.Verifying -> {
ContentVerifying(flowState) VerificationContentVerifying(flowState.data)
} }
else -> Unit else -> Unit
} }
@ -241,79 +232,22 @@ private fun ContentInitial(
} }
@Composable @Composable
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) { private fun VerifySelfSessionBottomMenu(
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> {
val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
Text(
modifier = Modifier.fillMaxWidth(),
text = text,
style = ElementTheme.typography.fontHeadingLgBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
}
is SessionVerificationData.Emojis -> {
// We want each row to have up to 4 emojis
val rows = verificationFlowStep.data.emojis.chunked(4)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(40.dp),
) {
rows.forEach { emojis ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
for (emoji in emojis) {
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
}
}
}
}
}
}
}
}
@Composable
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
val emojiResource = emoji.number.toEmojiResource()
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
Image(
modifier = Modifier.size(48.dp),
painter = painterResource(id = emojiResource.drawableRes),
contentDescription = null,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = emojiResource.nameRes),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun BottomMenu(
screenState: VerifySelfSessionState, screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit, onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit, onResetKey: () -> Unit,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onContinueClick: () -> Unit, onContinueClick: () -> Unit,
) { ) {
val verificationViewState = screenState.verificationFlowStep val verificationViewState = screenState.step
val eventSink = screenState.eventSink val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading<Unit> val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading<Unit>
when (verificationViewState) { when (verificationViewState) {
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is FlowStep.Initial -> { is Step.Initial -> {
BottomMenu { VerificationBottomMenu {
if (verificationViewState.isLastDevice) { if (verificationViewState.isLastDevice) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -340,8 +274,8 @@ private fun BottomMenu(
) )
} }
} }
is FlowStep.Canceled -> { is Step.Canceled -> {
BottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_positive_button_canceled), text = stringResource(R.string.screen_session_verification_positive_button_canceled),
@ -354,8 +288,8 @@ private fun BottomMenu(
) )
} }
} }
is FlowStep.Ready -> { is Step.Ready -> {
BottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start), text = stringResource(CommonStrings.action_start),
@ -368,8 +302,8 @@ private fun BottomMenu(
) )
} }
} }
is FlowStep.AwaitingOtherDeviceResponse -> { is Step.AwaitingOtherDeviceResponse -> {
BottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device), text = stringResource(R.string.screen_identity_waiting_on_other_device),
@ -380,13 +314,13 @@ private fun BottomMenu(
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))
} }
} }
is FlowStep.Verifying -> { is Step.Verifying -> {
val positiveButtonTitle = if (isVerifying) { val positiveButtonTitle = if (isVerifying) {
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing) stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
} else { } else {
stringResource(R.string.screen_session_verification_they_match) stringResource(R.string.screen_session_verification_they_match)
} }
BottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = positiveButtonTitle, text = positiveButtonTitle,
@ -404,8 +338,8 @@ private fun BottomMenu(
) )
} }
} }
is FlowStep.Completed -> { is Step.Completed -> {
BottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_continue), text = stringResource(CommonStrings.action_continue),
@ -415,19 +349,7 @@ private fun BottomMenu(
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
} }
} }
is FlowStep.Skipped -> return is Step.Skipped -> return
}
}
@Composable
private fun BottomMenu(
modifier: Modifier = Modifier,
buttons: @Composable ColumnScope.() -> Unit,
) {
ButtonColumnMolecule(
modifier = modifier.padding(bottom = 16.dp)
) {
buttons()
} }
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents { sealed interface VerifySelfSessionViewEvents {
data object RequestVerification : VerifySelfSessionViewEvents data object RequestVerification : VerifySelfSessionViewEvents

View file

@ -0,0 +1,33 @@
/*
* 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.verifysession.impl.ui
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
}
internal fun aDecimalsSessionVerificationData(
decimals: List<Int> = listOf(123, 456, 789),
): SessionVerificationData {
return SessionVerificationData.Decimals(decimals)
}
private fun aVerificationEmojiList() = listOf(
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
)

View file

@ -0,0 +1,27 @@
/*
* 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.verifysession.impl.ui
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@Composable
internal fun VerificationBottomMenu(
modifier: Modifier = Modifier,
buttons: @Composable ColumnScope.() -> Unit,
) {
ButtonColumnMolecule(
modifier = modifier.padding(bottom = 16.dp)
) {
buttons()
}
}

View file

@ -0,0 +1,94 @@
/*
* 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.verifysession.impl.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@Composable
internal fun VerificationContentVerifying(
data: SessionVerificationData,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (data) {
is SessionVerificationData.Decimals -> {
val text = data.decimals.joinToString(separator = " - ") { it.toString() }
Text(
modifier = Modifier.fillMaxWidth(),
text = text,
style = ElementTheme.typography.fontHeadingLgBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
}
is SessionVerificationData.Emojis -> {
// We want each row to have up to 4 emojis
val rows = data.emojis.chunked(4)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(40.dp),
) {
rows.forEach { emojis ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
for (emoji in emojis) {
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
}
}
}
}
}
}
}
}
@Composable
private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
val emojiResource = emoji.number.toEmojiResource()
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
Image(
modifier = Modifier.size(48.dp),
painter = painterResource(id = emojiResource.drawableRes),
contentDescription = null,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = emojiResource.nameRes),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -0,0 +1,25 @@
/*
* 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.verifysession.impl.util
import com.freeletics.flowredux.dsl.InStateBuilderBlock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
import com.freeletics.flowredux.dsl.State as MachineState
internal fun <T : Any> T.andLogStateChange() = also {
Timber.w("Verification: state machine state moved to [${this::class.simpleName}]")
}
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <State : Any, reified Event : Any> InStateBuilderBlock<State, State, Event>.logReceivedEvents() {
on { event: Event, state: MachineState<State> ->
Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]")
state.noChange()
}
}

View file

@ -0,0 +1,292 @@
/*
* 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.verifysession.impl.incoming
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class IncomingVerificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val approveVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
approveVerificationLambda = approveVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
assertThat(emojiState.step).isEqualTo(
IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false
)
)
// User claims that the emoji matches
emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
approveVerificationLambda.assertions().isCalledOnce()
// Remote confirm that the emojis match
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFinish
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed)
}
}
@Test
fun `present - emoji not matching case - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
// User claims that the emojis do not match
emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
declineVerificationLambda.assertions().isCalledOnce()
// Remote confirm that there is a failure
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFail
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
}
}
@Test
fun `present - incoming verification is remotely canceled`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val onFinishLambda = lambdaRecorder<Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
navigator = IncomingVerificationNavigator(onFinishLambda),
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
// Remote cancel the verification request
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
// The screen is dismissed
skipItems(2)
onFinishLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val declineVerificationLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
declineVerificationLambda = declineVerificationLambda,
resetLambda = resetLambda,
)
createPresenter(
service = fakeSessionVerificationService,
).test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
isWaiting = false,
)
)
resetLambda.assertions().isCalledOnce().with(value(false))
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
acceptVerificationRequestLambda.assertions().isNeverCalled()
// User accept the incoming verification
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
skipItems(1)
val initialWaitingState = awaitItem()
assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
advanceUntilIdle()
acceptVerificationRequestLambda.assertions().isCalledOnce()
// Remote sent the data
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidReceiveVerificationData(
data = aEmojisSessionVerificationData()
)
)
val emojiState = awaitItem()
// User goes back
emojiState.eventSink(IncomingVerificationViewEvents.GoBack)
val emojiWaitingItem = awaitItem()
assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
declineVerificationLambda.assertions().isCalledOnce()
// Remote confirm that there is a failure
fakeSessionVerificationService.emitVerificationFlowState(
VerificationFlowState.DidFail
)
val finalItem = awaitItem()
assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
}
}
@Test
fun `present - user ignores incoming request`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val fakeSessionVerificationService = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
resetLambda = resetLambda,
)
val navigatorLambda = lambdaRecorder<Unit> { }
createPresenter(
service = fakeSessionVerificationService,
navigator = IncomingVerificationNavigator(navigatorLambda),
).test {
val initialState = awaitItem()
initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification)
skipItems(1)
navigatorLambda.assertions().isCalledOnce()
}
}
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
senderId = A_USER_ID,
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
displayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP,
)
private fun createPresenter(
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
) = IncomingVerificationPresenter(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
navigator = navigator,
sessionVerificationService = service,
stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter,
)
}

View file

@ -0,0 +1,217 @@
/*
* 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.verifysession.impl.incoming
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class IncomingVerificationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
// region step Initial
@Test
fun `back key pressed - ignore the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `ignore incoming verification emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_ignore)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification)
}
@Test
fun `start incoming verification emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_start)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification)
}
@Test
fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = aStepInitial(
isWaiting = true,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion step Initial
// region step Verifying
@Test
fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `back key pressed - when verifying and loading emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = true,
),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_session_verification_they_dont_match)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification)
}
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
isWaiting = false,
),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification)
}
// endregion
// region step Failure
@Test
fun `back key pressed - when failure resets the flow`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Failure,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `click on done - when failure resets the flow`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Failure,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_done)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion
// region step Completed
@Test
fun `back key pressed - on Completed step emits the expected event`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Completed,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
@Test
fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() {
val eventsRecorder = EventsRecorder<IncomingVerificationViewEvents>()
rule.setIncomingVerificationView(
anIncomingVerificationState(
step = IncomingVerificationState.Step.Completed,
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_done)
eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
}
// endregion
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIncomingVerificationView(
state: IncomingVerificationState,
) {
setContent {
IncomingVerificationView(
state = state,
)
}
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
@ -14,12 +14,13 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutUseCase import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.logout.test.FakeLogoutUseCase import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
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.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -43,12 +45,14 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - Initial state is received`() = runTest { fun `present - Initial state is received`() = runTest {
val presenter = createVerifySelfSessionPresenter() val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().run { awaitItem().run {
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(step).isEqualTo(Step.Initial(false))
assertThat(displaySkipButton).isTrue() assertThat(displaySkipButton).isTrue()
} }
} }
@ -57,7 +61,10 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - hides skip verification button on non-debuggable builds`() = runTest { fun `present - hides skip verification button on non-debuggable builds`() = runTest {
val buildMeta = aBuildMeta(isDebuggable = false) val buildMeta = aBuildMeta(isDebuggable = false)
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta) val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
buildMeta = buildMeta,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -67,7 +74,11 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - Initial state is received, can use recovery key`() = runTest { fun `present - Initial state is received, can use recovery key`() = runTest {
val resetLambda = lambdaRecorder<Boolean, Unit> { }
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(
resetLambda = resetLambda
),
encryptionService = FakeEncryptionService().apply { encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
@ -75,13 +86,15 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true)) assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
resetLambda.assertions().isCalledOnce().with(value(true))
} }
} }
@Test @Test
fun `present - Initial state is received, can use recovery key and is last device`() = runTest { fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
encryptionService = FakeEncryptionService().apply { encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true) emitIsLastDevice(true)
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
@ -90,13 +103,16 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)) assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
} }
} }
@Test @Test
fun `present - Handles requestVerification`() = runTest { fun `present - Handles requestVerification`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -107,32 +123,36 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - Handles startSasVerification`() = runTest { fun `present - Handles startSasVerification`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(initialState.step).isEqualTo(Step.Initial(false))
val eventSink = initialState.eventSink initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response: // Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
// ChallengeReceived: // ChallengeReceived:
service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList())) service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
val verifyingState = awaitItem() val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
} }
} }
@Test @Test
fun `present - Cancelation on initial state does nothing`() = runTest { fun `present - Cancellation on initial state does nothing`() = runTest {
val presenter = createVerifySelfSessionPresenter() val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(initialState.step).isEqualTo(Step.Initial(false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel) eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents() expectNoEvents()
@ -141,92 +161,110 @@ class VerifySelfSessionPresenterTest {
@Test @Test
fun `present - A failure when verifying cancels it`() = runTest { fun `present - A failure when verifying cancels it`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
service.shouldFail = true
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification) state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
// Cancelling // Cancelling
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFail)
// Cancelled // Cancelled
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
} }
} }
@Test @Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest { fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
service.shouldFail = true
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification) awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java) assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
} }
} }
@Test @Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest { fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
cancelVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.Cancel) state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
} }
} }
@Test @Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
requestVerificationAndAwaitVerifyingState(service) requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList()))) service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
} }
@Test @Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest { fun `present - Restart after cancellation returns to requesting verification`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled) service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification // Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - Go back after cancelation returns to initial state`() = runTest { fun `present - Go back after cancellation returns to initial state`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled) service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset) state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state // Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -236,7 +274,11 @@ class VerifySelfSessionPresenterTest {
val emojis = listOf( val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley") VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
) )
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -246,54 +288,65 @@ class VerifySelfSessionPresenterTest {
SessionVerificationData.Emojis(emojis) SessionVerificationData.Emojis(emojis)
) )
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification) state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo( assertThat(awaitItem().step).isEqualTo(
VerificationStep.Verifying( Step.Verifying(
SessionVerificationData.Emojis(emojis), SessionVerificationData.Emojis(emojis),
AsyncData.Loading(), AsyncData.Loading(),
) )
) )
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed) service.emitVerificationFlowState(VerificationFlowState.DidFinish)
assertThat(awaitItem().step).isEqualTo(Step.Completed)
} }
} }
@Test @Test
fun `present - When verification is declined, the flow is canceled`() = runTest { fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
declineVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo( assertThat(awaitItem().step).isEqualTo(
VerificationStep.Verifying( Step.Verifying(
SessionVerificationData.Emojis(emptyList()), SessionVerificationData.Emojis(emptyList()),
AsyncData.Loading(), AsyncData.Loading(),
) )
) )
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
} }
} }
@Test @Test
fun `present - Skip event skips the flow`() = runTest { fun `present - Skip event skips the flow`() = runTest {
val service = unverifiedSessionService() val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification) state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped) assertThat(awaitItem().step).isEqualTo(Step.Skipped)
} }
} }
@Test @Test
fun `present - When verification is done using recovery key, the flow is completed`() = runTest { fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
val service = FakeSessionVerificationService().apply { val service = FakeSessionVerificationService(
givenNeedsSessionVerification(false) resetLambda = { },
givenVerifiedStatus(SessionVerifiedStatus.Verified) ).apply {
givenVerificationFlowState(VerificationFlowState.Finished) emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
} }
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = service, service = service,
@ -302,16 +355,18 @@ class VerifySelfSessionPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed) assertThat(awaitItem().step).isEqualTo(Step.Completed)
} }
} }
@Test @Test
fun `present - When verification is not needed, the flow is skipped`() = runTest { fun `present - When verification is not needed, the flow is skipped`() = runTest {
val service = FakeSessionVerificationService().apply { val service = FakeSessionVerificationService(
givenNeedsSessionVerification(false) resetLambda = { },
givenVerifiedStatus(SessionVerifiedStatus.Verified) ).apply {
givenVerificationFlowState(VerificationFlowState.Finished) emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
} }
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = service, service = service,
@ -321,16 +376,18 @@ class VerifySelfSessionPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped) assertThat(awaitItem().step).isEqualTo(Step.Skipped)
} }
} }
@Test @Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest { fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
val service = FakeSessionVerificationService().apply { val service = FakeSessionVerificationService(
givenNeedsSessionVerification(false) resetLambda = { },
givenVerifiedStatus(SessionVerifiedStatus.Verified) ).apply {
givenVerificationFlowState(VerificationFlowState.Finished) emitNeedsSessionVerification(false)
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
} }
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" } val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
@ -356,33 +413,53 @@ class VerifySelfSessionPresenterTest {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState { ): VerifySelfSessionState {
var state = awaitItem() var state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(state.step).isEqualTo(Step.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response: // Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
state = awaitItem() state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
// Await for the state to be Ready // Await for the state to be Ready
state = awaitItem() state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready) assertThat(state.step).isEqualTo(Step.Ready)
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification) state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response (again): // Await for other device response (again):
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
state = awaitItem() state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
fakeService.triggerReceiveVerificationData(sessionVerificationData)
// Finally, ChallengeReceived: // Finally, ChallengeReceived:
fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
state = awaitItem() state = awaitItem()
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
return state return state
} }
private fun unverifiedSessionService(): FakeSessionVerificationService { private suspend fun unverifiedSessionService(
return FakeSessionVerificationService().apply { requestVerificationLambda: () -> Unit = { lambdaError() },
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) cancelVerificationLambda: () -> Unit = { lambdaError() },
approveVerificationLambda: () -> Unit = { lambdaError() },
declineVerificationLambda: () -> Unit = { lambdaError() },
startVerificationLambda: () -> Unit = { lambdaError() },
resetLambda: (Boolean) -> Unit = { },
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
): FakeSessionVerificationService {
return FakeSessionVerificationService(
requestVerificationLambda = requestVerificationLambda,
cancelVerificationLambda = cancelVerificationLambda,
approveVerificationLambda = approveVerificationLambda,
declineVerificationLambda = declineVerificationLambda,
startVerificationLambda = startVerificationLambda,
resetLambda = resetLambda,
acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
acceptVerificationRequestLambda = acceptVerificationRequestLambda,
).apply {
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
} }
} }
private fun createVerifySelfSessionPresenter( private fun createVerifySelfSessionPresenter(
service: SessionVerificationService = unverifiedSessionService(), service: SessionVerificationService,
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),

View file

@ -5,12 +5,14 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled, step = VerifySelfSessionState.Step.Canceled,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse, step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready, step = VerifySelfSessionState.Step.Ready,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(), data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized, state = AsyncData.Uninitialized,
), ),
@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(), data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(), state = AsyncData.Loading(),
), ),
@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed, step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed, step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onFinished = callback, onFinished = callback,
@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onEnterRecoveryKey = callback, onEnterRecoveryKey = callback,
@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onLearnMoreClick = callback, onLearnMoreClick = callback,
@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(), data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized, state = AsyncData.Uninitialized,
), ),
@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(), data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized, state = AsyncData.Uninitialized,
), ),
@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true), step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
displaySkipButton = true, displaySkipButton = true,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setVerifySelfSessionView( rule.setVerifySelfSessionView(
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped, step = VerifySelfSessionState.Step.Skipped,
displaySkipButton = true, displaySkipButton = true,
eventSink = EnsureNeverCalledWithParam(), eventSink = EnsureNeverCalledWithParam(),
), ),

View file

@ -4,9 +4,9 @@
[versions] [versions]
# Project # Project
android_gradle_plugin = "8.7.1" android_gradle_plugin = "8.7.1"
kotlin = "2.0.20" kotlin = "2.0.21"
kotlinpoet = "2.0.0" kotlinpoet = "2.0.0"
ksp = "2.0.20-1.0.25" ksp = "2.0.21-1.0.26"
firebaseAppDistribution = "5.0.0" firebaseAppDistribution = "5.0.0"
# AndroidX # AndroidX
@ -22,14 +22,14 @@ constraintlayout_compose = "1.0.1"
lifecycle = "2.8.6" lifecycle = "2.8.6"
activity = "1.9.3" activity = "1.9.3"
media3 = "1.4.1" media3 = "1.4.1"
camera = "1.3.4" camera = "1.4.0"
# Compose # Compose
compose_bom = "2024.10.00" compose_bom = "2024.10.00"
composecompiler = "1.5.15" composecompiler = "1.5.15"
# Coroutines # Coroutines
coroutines = "1.8.1" coroutines = "1.9.0"
# Accompanist # Accompanist
accompanist = "0.36.0" accompanist = "0.36.0"
@ -37,17 +37,21 @@ accompanist = "0.36.0"
# Test # Test
test_core = "1.6.1" test_core = "1.6.1"
# Jetbrain
datetime = "0.6.1"
serialization_json = "1.7.3"
#other #other
coil = "2.7.0" coil = "2.7.0"
datetime = "0.6.0"
dependencyAnalysis = "2.3.0"
serialization_json = "1.6.3"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.2" sqldelight = "2.0.2"
wysiwyg = "2.37.13" wysiwyg = "2.37.13"
telephoto = "0.13.0" telephoto = "0.13.0"
# Dependency analysis
dependencyAnalysis = "2.4.0"
# DI # DI
dagger = "2.52" dagger = "2.52"
anvil = "0.3.3" anvil = "0.3.3"
@ -81,7 +85,7 @@ ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version
# AndroidX # AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.0" androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.1"
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7" androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
@ -162,14 +166,14 @@ coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "0.1.1" } compound = { module = "io.element.android:compound-android", version = "0.1.1" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7" kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.18.1" jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.57" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.58"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -185,15 +189,15 @@ telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.5.2" maplibre = "org.maplibre.gl:android-sdk:11.5.2"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0" opusencoder = "io.element.android:opusencoder:1.1.0"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.8.2" posthog = "com.posthog:posthog-android:3.9.0"
sentry = "io.sentry:sentry-android:7.16.0" sentry = "io.sentry:sentry-android:7.16.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.27.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
# Emojibase # Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3" matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3"

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.core.coroutine package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn
* A [StateFlow] that derives its value from a [Flow]. * A [StateFlow] that derives its value from a [Flow].
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow]. * Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
*/ */
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
class DerivedStateFlow<T>( class DerivedStateFlow<T>(
private val getValue: () -> T, private val getValue: () -> T,
private val flow: Flow<T> private val flow: Flow<T>

View file

@ -11,8 +11,9 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
const val A_FORMATTED_DATE = "formatted_date" const val A_FORMATTED_DATE = "formatted_date"
class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter { class FakeLastMessageTimestampFormatter(
private var format = "" var format: String = "",
) : LastMessageTimestampFormatter {
fun givenFormat(format: String) { fun givenFormat(format: String) {
this.format = format this.format = format
} }

View file

@ -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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TextWithLabelMolecule(
label: String,
text: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = label,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}

View file

@ -42,6 +42,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
* @param trailingContent The content to be displayed after the headline content. * @param trailingContent The content to be displayed after the headline content.
* @param style The style to use for the list item. This may change the color and text styles of the contents. [ListItemStyle.Default] is used by default. * @param style The style to use for the list item. This may change the color and text styles of the contents. [ListItemStyle.Default] is used by default.
* @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens.
* @param alwaysClickable Whether the list item should always be clickable, even when disabled.
* @param onClick The callback to be called when the list item is clicked. * @param onClick The callback to be called when the list item is clicked.
*/ */
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -54,6 +55,7 @@ fun ListItem(
trailingContent: ListItemContent? = null, trailingContent: ListItemContent? = null,
style: ListItemStyle = ListItemStyle.Default, style: ListItemStyle = ListItemStyle.Default,
enabled: Boolean = true, enabled: Boolean = true,
alwaysClickable: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val colors = ListItemDefaults.colors( val colors = ListItemDefaults.colors(
@ -74,6 +76,7 @@ fun ListItem(
trailingContent = trailingContent, trailingContent = trailingContent,
colors = colors, colors = colors,
enabled = enabled, enabled = enabled,
alwaysClickable = alwaysClickable,
onClick = onClick, onClick = onClick,
) )
} }
@ -87,6 +90,7 @@ fun ListItem(
* @param leadingContent The content to be displayed before the headline content. * @param leadingContent The content to be displayed before the headline content.
* @param trailingContent The content to be displayed after the headline content. * @param trailingContent The content to be displayed after the headline content.
* @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens.
* @param alwaysClickable Whether the list item should always be clickable, even when disabled.
* @param onClick The callback to be called when the list item is clicked. * @param onClick The callback to be called when the list item is clicked.
*/ */
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -99,6 +103,7 @@ fun ListItem(
leadingContent: ListItemContent? = null, leadingContent: ListItemContent? = null,
trailingContent: ListItemContent? = null, trailingContent: ListItemContent? = null,
enabled: Boolean = true, enabled: Boolean = true,
alwaysClickable: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
// We cannot just pass the disabled colors, they must be set manually: https://issuetracker.google.com/issues/280480132 // We cannot just pass the disabled colors, they must be set manually: https://issuetracker.google.com/issues/280480132
@ -149,7 +154,7 @@ fun ListItem(
headlineContent = decoratedHeadlineContent, headlineContent = decoratedHeadlineContent,
modifier = if (onClick != null) { modifier = if (onClick != null) {
Modifier Modifier
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled || alwaysClickable, onClick = onClick)
.then(modifier) .then(modifier)
} else { } else {
modifier modifier

View file

@ -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.core
import java.io.Serializable
@JvmInline
value class FlowId(val value: String) : Serializable {
override fun toString(): String = value
}

View file

@ -0,0 +1,23 @@
/*
* 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.verification
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class SessionVerificationRequestDetails(
val senderId: UserId,
val flowId: FlowId,
val deviceId: DeviceId,
val displayName: String?,
val firstSeenTimestamp: Long,
) : Parcelable

View file

@ -56,7 +56,27 @@ interface SessionVerificationService {
/** /**
* Returns the verification service state to the initial step. * Returns the verification service state to the initial step.
*/ */
suspend fun reset() suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean)
/**
* Register a listener to be notified of incoming session verification requests.
*/
fun setListener(listener: SessionVerificationServiceListener?)
/**
* Set this particular request as the currently active one and register for
* events pertaining it.
*/
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
/**
* Accept the previously acknowledged verification request.
*/
suspend fun acceptVerificationRequest()
}
interface SessionVerificationServiceListener {
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
} }
/** Verification status of the current session. */ /** Verification status of the current session. */
@ -82,20 +102,20 @@ sealed interface VerificationFlowState {
data object Initial : VerificationFlowState data object Initial : VerificationFlowState
/** Session verification request was accepted by another device. */ /** Session verification request was accepted by another device. */
data object AcceptedVerificationRequest : VerificationFlowState data object DidAcceptVerificationRequest : VerificationFlowState
/** Short Authentication String (SAS) verification started between the 2 devices. */ /** Short Authentication String (SAS) verification started between the 2 devices. */
data object StartedSasVerification : VerificationFlowState data object DidStartSasVerification : VerificationFlowState
/** Verification data for the SAS verification received. */ /** Verification data for the SAS verification received. */
data class ReceivedVerificationData(val data: SessionVerificationData) : VerificationFlowState data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState
/** Verification completed successfully. */ /** Verification completed successfully. */
data object Finished : VerificationFlowState data object DidFinish : VerificationFlowState
/** Verification was cancelled by either device. */ /** Verification was cancelled by either device. */
data object Canceled : VerificationFlowState data object DidCancel : VerificationFlowState
/** Verification failed with an error. */ /** Verification failed with an error. */
data object Failed : VerificationFlowState data object DidFail : VerificationFlowState
} }

View file

@ -11,23 +11,28 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.impl.room.toRoomType import io.element.android.libraries.matrix.impl.room.toRoomType
import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.RoomPreview as RustRoomPreview import org.matrix.rustcomponents.sdk.RoomPreview as RustRoomPreview
object RoomPreviewMapper { object RoomPreviewMapper {
fun map(roomPreview: RustRoomPreview): RoomPreview { fun map(roomPreview: RustRoomPreview): RoomPreview {
return RoomPreview( return roomPreview.use {
roomId = RoomId(roomPreview.roomId), val info = roomPreview.info()
canonicalAlias = roomPreview.canonicalAlias?.let(::RoomAlias), RoomPreview(
name = roomPreview.name, roomId = RoomId(info.roomId),
topic = roomPreview.topic, canonicalAlias = info.canonicalAlias?.let(::RoomAlias),
avatarUrl = roomPreview.avatarUrl, name = info.name,
numberOfJoinedMembers = roomPreview.numJoinedMembers.toLong(), topic = info.topic,
roomType = roomPreview.roomType.toRoomType(), avatarUrl = info.avatarUrl,
isHistoryWorldReadable = roomPreview.isHistoryWorldReadable, numberOfJoinedMembers = info.numJoinedMembers.toLong(),
isJoined = roomPreview.isJoined, roomType = info.roomType.toRoomType(),
isInvited = roomPreview.isInvited, isHistoryWorldReadable = info.isHistoryWorldReadable,
isPublic = roomPreview.isPublic, isJoined = info.membership == Membership.JOINED,
canKnock = roomPreview.canKnock isInvited = info.membership == Membership.INVITED,
) isPublic = info.joinRule == JoinRule.Public,
canKnock = info.joinRule == JoinRule.Knock
)
}
} }
} }

View file

@ -9,12 +9,15 @@ package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.Encryption
@ -41,6 +45,7 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
class RustSessionVerificationService( class RustSessionVerificationService(
private val client: Client, private val client: Client,
@ -100,6 +105,16 @@ class RustSessionVerificationService(
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
} }
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
listener?.onIncomingSessionRequest(details.map())
}
private var listener: SessionVerificationServiceListener? = null
override fun setListener(listener: SessionVerificationServiceListener?) {
this.listener = listener
}
override suspend fun requestVerification() = tryOrFail { override suspend fun requestVerification() = tryOrFail {
initVerificationControllerIfNeeded() initVerificationControllerIfNeeded()
verificationController.requestVerification() verificationController.requestVerification()
@ -119,9 +134,24 @@ class RustSessionVerificationService(
verificationController.startSasVerification() verificationController.startSasVerification()
} }
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
verificationController.acknowledgeVerificationRequest(
senderId = details.senderId.value,
flowId = details.flowId.value,
)
}
override suspend fun acceptVerificationRequest() = tryOrFail {
verificationController.acceptVerificationRequest()
}
private suspend fun tryOrFail(block: suspend () -> Unit) { private suspend fun tryOrFail(block: suspend () -> Unit) {
runCatching { runCatching {
block() // Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution,
// the state machine may cancel the api call.
withContext(NonCancellable) {
block()
}
}.onFailure { }.onFailure {
Timber.e(it, "Failed to verify session") Timber.e(it, "Failed to verify session")
didFail() didFail()
@ -132,16 +162,16 @@ class RustSessionVerificationService(
// When verification attempt is accepted by the other device // When verification attempt is accepted by the other device
override fun didAcceptVerificationRequest() { override fun didAcceptVerificationRequest() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest _verificationFlowState.value = VerificationFlowState.DidAcceptVerificationRequest
} }
override fun didCancel() { override fun didCancel() {
_verificationFlowState.value = VerificationFlowState.Canceled _verificationFlowState.value = VerificationFlowState.DidCancel
} }
override fun didFail() { override fun didFail() {
Timber.e("Session verification failed with an unknown error") Timber.e("Session verification failed with an unknown error")
_verificationFlowState.value = VerificationFlowState.Failed _verificationFlowState.value = VerificationFlowState.DidFail
} }
override fun didFinish() { override fun didFinish() {
@ -150,14 +180,14 @@ class RustSessionVerificationService(
// It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed // It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
runCatching { runCatching {
withTimeout(30.seconds) { withTimeout(30.seconds) {
while (!verificationController.isVerified()) { while (encryptionService.verificationState() != VerificationState.VERIFIED) {
delay(100) delay(100)
} }
} }
} }
.onSuccess { .onSuccess {
// Order here is important, first set the flow state as finished, then update the verification status // Order here is important, first set the flow state as finished, then update the verification status
_verificationFlowState.value = VerificationFlowState.Finished _verificationFlowState.value = VerificationFlowState.DidFinish
updateVerificationStatus() updateVerificationStatus()
} }
.onFailure { .onFailure {
@ -168,18 +198,18 @@ class RustSessionVerificationService(
} }
override fun didReceiveVerificationData(data: RustSessionVerificationData) { override fun didReceiveVerificationData(data: RustSessionVerificationData) {
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(data.map()) _verificationFlowState.value = VerificationFlowState.DidReceiveVerificationData(data.map())
} }
// When the actual SAS verification starts // When the actual SAS verification starts
override fun didStartSasVerification() { override fun didStartSasVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification _verificationFlowState.value = VerificationFlowState.DidStartSasVerification
} }
// end-region // end-region
override suspend fun reset() { override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
if (isReady.value) { if (isReady.value && cancelAnyPendingVerificationAttempt) {
// Cancel any pending verification attempt // Cancel any pending verification attempt
tryOrNull { verificationController.cancelVerification() } tryOrNull { verificationController.cancelVerification() }
} }
@ -208,7 +238,7 @@ class RustSessionVerificationService(
} }
private suspend fun updateVerificationStatus() { private suspend fun updateVerificationStatus() {
if (verificationFlowState.value == VerificationFlowState.Finished) { if (verificationFlowState.value == VerificationFlowState.DidFinish) {
// Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network // Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network
// So we need to check that *only* if we know there is network connection, which is the case when the verification flow just finished // So we need to check that *only* if we know there is network connection, which is the case when the verification flow just finished
Timber.d("Updating verification status: flow just finished") Timber.d("Updating verification status: flow just finished")
@ -227,7 +257,7 @@ class RustSessionVerificationService(
Timber.d("Updating verification status: flow is pending or was finished some time ago") Timber.d("Updating verification status: flow is pending or was finished some time ago")
runCatching { runCatching {
initVerificationControllerIfNeeded() initVerificationControllerIfNeeded()
_sessionVerifiedStatus.value = if (verificationController.isVerified()) { _sessionVerifiedStatus.value = if (encryptionService.verificationState() == VerificationState.VERIFIED) {
SessionVerifiedStatus.Verified SessionVerifiedStatus.Verified
} else { } else {
SessionVerifiedStatus.NotVerified SessionVerifiedStatus.NotVerified

View file

@ -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.libraries.matrix.impl.verification
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderId = UserId(senderId),
flowId = FlowId(flowId),
deviceId = DeviceId(deviceId),
displayName = displayName,
firstSeenTimestamp = firstSeenTimestamp.toLong(),
)

View file

@ -9,16 +9,16 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import org.matrix.rustcomponents.sdk.RoomPreview import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.RoomPreviewInfo
internal fun aRustRoomPreview( internal fun aRustRoomPreviewInfo(
canonicalAlias: String? = A_ROOM_ALIAS.value, canonicalAlias: String? = A_ROOM_ALIAS.value,
isJoined: Boolean = true, membership: Membership? = Membership.JOINED,
isInvited: Boolean = true, joinRule: JoinRule = JoinRule.Public,
isPublic: Boolean = true, ): RoomPreviewInfo {
canKnock: Boolean = true, return RoomPreviewInfo(
): RoomPreview {
return RoomPreview(
roomId = A_ROOM_ID.value, roomId = A_ROOM_ID.value,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
name = "name", name = "name",
@ -27,9 +27,7 @@ internal fun aRustRoomPreview(
numJoinedMembers = 1u, numJoinedMembers = 1u,
roomType = null, roomType = null,
isHistoryWorldReadable = true, isHistoryWorldReadable = true,
isJoined = isJoined, membership = membership,
isInvited = isInvited, joinRule = joinRule,
isPublic = isPublic,
canKnock = canKnock,
) )
} }

View file

@ -0,0 +1,21 @@
/*
* 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.fixtures.fakes
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPreviewInfo
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.RoomPreview
import org.matrix.rustcomponents.sdk.RoomPreviewInfo
class FakeRustRoomPreview(
private val info: RoomPreviewInfo = aRustRoomPreviewInfo(),
) : RoomPreview(NoPointer) {
override fun info(): RoomPreviewInfo {
return info
}
}

View file

@ -10,19 +10,23 @@ package io.element.android.libraries.matrix.impl.room.preview
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPreview import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPreviewInfo
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomPreview
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import org.junit.Test import org.junit.Test
import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.Membership
class RoomPreviewMapperTest { class RoomPreviewMapperTest {
@Test @Test
fun `map should map values 1`() { fun `map should map values 1`() {
assertThat( assertThat(
RoomPreviewMapper.map( RoomPreviewMapper.map(
aRustRoomPreview( FakeRustRoomPreview(
isJoined = false, info = aRustRoomPreviewInfo(
isInvited = false, membership = null,
)
) )
) )
).isEqualTo( ).isEqualTo(
@ -38,7 +42,7 @@ class RoomPreviewMapperTest {
isJoined = false, isJoined = false,
isInvited = false, isInvited = false,
isPublic = true, isPublic = true,
canKnock = true, canKnock = false,
) )
) )
} }
@ -47,10 +51,12 @@ class RoomPreviewMapperTest {
fun `map should map values 2`() { fun `map should map values 2`() {
assertThat( assertThat(
RoomPreviewMapper.map( RoomPreviewMapper.map(
aRustRoomPreview( FakeRustRoomPreview(
canonicalAlias = null, info = aRustRoomPreviewInfo(
isPublic = false, canonicalAlias = null,
canKnock = false, membership = Membership.JOINED,
joinRule = JoinRule.Knock,
)
) )
) )
).isEqualTo( ).isEqualTo(
@ -64,9 +70,9 @@ class RoomPreviewMapperTest {
roomType = RoomType.Room, roomType = RoomType.Room,
isHistoryWorldReadable = true, isHistoryWorldReadable = true,
isJoined = true, isJoined = true,
isInvited = true, isInvited = false,
isPublic = false, isPublic = false,
canKnock = false, canKnock = true,
) )
) )
} }

Some files were not shown because too many files have changed in this diff Show more