Merge branch 'develop' into andybalaam/enable-identity-violation-notifs-unconditionally
This commit is contained in:
commit
50471f8707
305 changed files with 3609 additions and 1449 deletions
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.20" />
|
||||
<option name="version" value="2.0.21" />
|
||||
</component>
|
||||
</project>
|
||||
16
CHANGES.md
16
CHANGES.md
|
|
@ -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)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
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.replace
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
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.share.api.ShareEntryPoint
|
||||
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.BaseFlowNode
|
||||
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.permalink.PermalinkData
|
||||
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.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
|
|
@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
matrixClient.roomMembershipObserver(),
|
||||
)
|
||||
|
||||
private val verificationListener = object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
|
|
@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
// TODO We do not support Space yet, so directly navigate to main space
|
||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||
|
||||
ftueService.state
|
||||
.onEach { ftueState ->
|
||||
|
|
@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
matrixClient.sessionVerificationService().setListener(null)
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
|
|
@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -260,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onSetUpRecoveryClick() {
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery))
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
|
||||
}
|
||||
|
||||
override fun onSessionConfirmRecoveryKeyClick() {
|
||||
|
|
@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.IncomingVerificationRequest -> {
|
||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||
.callback(object : IncomingVerificationEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ allprojects {
|
|||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||
}
|
||||
dependencies {
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.16")
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.17")
|
||||
}
|
||||
|
||||
// KtLint
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class DefaultFtueServiceTest {
|
|||
@Test
|
||||
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
}
|
||||
val service = createDefaultFtueService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
|
|
@ -46,7 +46,7 @@ class DefaultFtueServiceTest {
|
|||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ class DefaultFtueServiceTest {
|
|||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
|
@ -76,7 +76,7 @@ class DefaultFtueServiceTest {
|
|||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
|
|
@ -91,7 +91,7 @@ class DefaultFtueServiceTest {
|
|||
|
||||
// Session verification
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
|
|
@ -132,7 +132,7 @@ class DefaultFtueServiceTest {
|
|||
)
|
||||
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ class DefaultFtueServiceTest {
|
|||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@ import kotlinx.parcelize.Parcelize
|
|||
@Immutable
|
||||
sealed interface Attachment : Parcelable {
|
||||
@Parcelize
|
||||
data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment
|
||||
data class Media(val localMedia: LocalMedia) : Attachment
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
mediaSender.sendMedia(
|
||||
uri = mediaAttachment.localMedia.uri,
|
||||
mimeType = mediaAttachment.localMedia.info.mimeType,
|
||||
compressIfPossible = mediaAttachment.compressIfPossible,
|
||||
progressCallback = progressCallback
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ fun anAttachmentsPreviewState(
|
|||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
compressIfPossible = true
|
||||
),
|
||||
sendActionState = sendActionState,
|
||||
eventSink = {}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
|
||||
handlePickedMedia(attachmentsState, uri, compressIfPossible = false)
|
||||
handlePickedMedia(attachmentsState, uri)
|
||||
}
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
|
||||
|
|
@ -294,7 +294,6 @@ class MessageComposerPresenter @Inject constructor(
|
|||
name = null,
|
||||
formattedFileSize = null
|
||||
),
|
||||
compressIfPossible = true
|
||||
),
|
||||
attachmentState = attachmentsState,
|
||||
)
|
||||
|
|
@ -493,7 +492,6 @@ class MessageComposerPresenter @Inject constructor(
|
|||
attachmentsState: MutableState<AttachmentsState>,
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
compressIfPossible: Boolean = true,
|
||||
) {
|
||||
if (uri == null) {
|
||||
attachmentsState.value = AttachmentsState.None
|
||||
|
|
@ -505,7 +503,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
name = null,
|
||||
formattedFileSize = null
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
|
||||
val mediaAttachment = Attachment.Media(localMedia)
|
||||
val isPreviewable = when {
|
||||
MimeTypes.isImage(localMedia.info.mimeType) -> true
|
||||
MimeTypes.isVideo(localMedia.info.mimeType) -> true
|
||||
|
|
@ -535,7 +533,6 @@ class MessageComposerPresenter @Inject constructor(
|
|||
mediaSender.sendMedia(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
compressIfPossible = false,
|
||||
progressCallback = progressCallback
|
||||
).getOrThrow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -522,32 +522,33 @@ private fun MessageEventBubbleContent(
|
|||
fun CommonLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
showThreadDecoration: Boolean,
|
||||
paddingBehaviour: ContentPadding,
|
||||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier,
|
||||
canShrinkContent: Boolean = false,
|
||||
) {
|
||||
val timestampLayoutModifier: Modifier
|
||||
val contentModifier: Modifier
|
||||
when {
|
||||
inReplyToDetails != null -> {
|
||||
if (timestampPosition == TimestampPosition.Overlay) {
|
||||
timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||
val timestampLayoutModifier =
|
||||
if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) {
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
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 {
|
||||
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
|
||||
timestampLayoutModifier = Modifier
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
}
|
||||
}
|
||||
timestampPosition != TimestampPosition.Overlay -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
contentModifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
}
|
||||
else -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
contentModifier = Modifier
|
||||
}
|
||||
ContentPadding.CaptionedMedia ->
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
|
||||
}
|
||||
|
||||
val threadDecoration = @Composable {
|
||||
if (showThreadDecoration) {
|
||||
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
|
||||
|
|
@ -601,9 +602,17 @@ private fun MessageEventBubbleContent(
|
|||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
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(
|
||||
showThreadDecoration = event.isThreaded,
|
||||
timestampPosition = timestampPosition,
|
||||
paddingBehaviour = paddingBehaviour,
|
||||
inReplyToDetails = event.inReplyTo,
|
||||
canShrinkContent = event.content is TimelineItemVoiceContent,
|
||||
modifier = bubbleModifier.semantics(mergeDescendants = true) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
|
|||
isMine = it,
|
||||
timelineItemReactions = aTimelineItemReactions(count = 0),
|
||||
content = aTimelineItemImageContent(
|
||||
aspectRatio = 2.5f
|
||||
aspectRatio = 2.5f,
|
||||
filename = "image.jpg",
|
||||
caption = "A reply with an image.",
|
||||
),
|
||||
inReplyTo = inReplyToDetails,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
|
|
|
|||
|
|
@ -69,9 +69,7 @@ fun TimelineItemImageView(
|
|||
modifier = modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
|
@ -119,6 +117,7 @@ fun TimelineItemImageView(
|
|||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
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),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ fun TimelineItemVideoView(
|
|||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
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),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.TimeZone
|
||||
|
||||
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
|
||||
override val values: Sequence<AggregatedReaction>
|
||||
|
|
@ -29,7 +30,9 @@ fun anAggregatedReaction(
|
|||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
): 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 senders = buildList {
|
||||
repeat(count) { index ->
|
||||
|
|
|
|||
|
|
@ -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.AttachmentsPreviewPresenter
|
||||
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.media.FileInfo
|
||||
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.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.mediaviewer.api.local.LocalMedia
|
||||
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.lambda.lambdaRecorder
|
||||
import io.mockk.mockk
|
||||
|
|
@ -33,6 +36,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class AttachmentsPreviewPresenterTest {
|
||||
@get:Rule
|
||||
|
|
@ -43,7 +47,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media success scenario`() = runTest {
|
||||
val sendMediaResult = lambdaRecorder<ProgressCallback?, Result<FakeMediaUploadHandler>> {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -52,7 +56,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
Pair(5, 10),
|
||||
Pair(10, 10)
|
||||
),
|
||||
sendMediaResult = sendMediaResult,
|
||||
sendFileResult = sendFileResult,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
|
|
@ -67,18 +71,18 @@ class AttachmentsPreviewPresenterTest {
|
|||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send media failure scenario`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
val sendMediaResult = lambdaRecorder<ProgressCallback?, Result<FakeMediaUploadHandler>> {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
sendMediaResult = sendMediaResult,
|
||||
sendFileResult = sendFileResult,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
|
|
@ -91,7 +95,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
|
|
@ -120,8 +124,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
room: MatrixRoom = FakeMatrixRoom()
|
||||
): AttachmentsPreviewPresenter {
|
||||
return AttachmentsPreviewPresenter(
|
||||
attachment = Attachment.Media(localMedia, compressIfPossible = false),
|
||||
mediaSender = MediaSender(mediaPreProcessor, room)
|
||||
attachment = aMediaAttachment(localMedia),
|
||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.libraries.mediaviewer.api.local.LocalMedia
|
||||
|
||||
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||
fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media(
|
||||
localMedia = localMedia,
|
||||
compressIfPossible = compressIfPossible,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.ProgressCallback
|
||||
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.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
|
|
@ -684,7 +685,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Pick file from storage`() = runTest {
|
||||
val sendMediaResult = lambdaRecorder { _: ProgressCallback? ->
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -693,7 +694,7 @@ class MessageComposerPresenterTest {
|
|||
Pair(5, 10),
|
||||
Pair(10, 10)
|
||||
),
|
||||
sendMediaResult = sendMediaResult,
|
||||
sendFileResult = sendFileResult,
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
)
|
||||
val presenter = createPresenter(this, room = room)
|
||||
|
|
@ -710,7 +711,7 @@ class MessageComposerPresenterTest {
|
|||
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
|
||||
val sentState = awaitItem()
|
||||
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -852,8 +853,11 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Uploading media failure can be recovered from`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
Result.failure(Exception())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
sendMediaResult = { Result.failure(Exception()) },
|
||||
sendFileResult = sendFileResult,
|
||||
typingNoticeResult = { Result.success(Unit) }
|
||||
)
|
||||
val presenter = createPresenter(this, room = room)
|
||||
|
|
@ -1489,7 +1493,7 @@ class MessageComposerPresenterTest {
|
|||
featureFlagService,
|
||||
sessionPreferencesStore,
|
||||
localMediaFactory,
|
||||
MediaSender(mediaPreProcessor, room),
|
||||
MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||
snackbarDispatcher,
|
||||
analyticsService,
|
||||
DefaultMessageComposerContext(),
|
||||
|
|
|
|||
|
|
@ -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.test.FakeMessageComposerContext
|
||||
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.room.FakeMatrixRoom
|
||||
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.test.FakePermissionsPresenter
|
||||
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.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
|
|
@ -45,6 +47,7 @@ import kotlinx.coroutines.test.TestScope
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -56,12 +59,15 @@ class VoiceMessageComposerPresenterTest {
|
|||
recordingDuration = RECORDING_DURATION
|
||||
)
|
||||
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(
|
||||
sendMediaResult = sendMediaResult
|
||||
sendVoiceMessageResult = sendVoiceMessageResult
|
||||
)
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
||||
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
||||
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom, InMemorySessionPreferencesStore())
|
||||
private val messageComposerContext = FakeMessageComposerContext()
|
||||
|
||||
companion object {
|
||||
|
|
@ -291,7 +297,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
|
|
@ -342,7 +348,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
|
|
@ -365,7 +371,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
|
|
@ -389,7 +395,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
|
||||
sendMediaResult.assertions().isNeverCalled()
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
assertThat(analyticsService.trackedErrors).hasSize(0)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
|
|
@ -414,13 +420,13 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
ensureAllEventsConsumed()
|
||||
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
sendMediaResult.assertions().isNeverCalled()
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
|
||||
mediaPreProcessor.givenAudioResult()
|
||||
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendMediaResult.assertions().isCalledOnce()
|
||||
sendVoiceMessageResult.assertions().isCalledOnce()
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
|
|
@ -457,7 +463,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
assertThat(showSendFailureDialog).isFalse()
|
||||
}
|
||||
|
||||
sendMediaResult.assertions().isNeverCalled()
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
|
@ -473,7 +479,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
sendMediaResult.assertions().isNeverCalled()
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
assertThat(analyticsService.trackedErrors).hasSize(1)
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
|
||||
|
|
@ -492,7 +498,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
sendMediaResult.assertions().isNeverCalled()
|
||||
sendVoiceMessageResult.assertions().isNeverCalled()
|
||||
assertThat(analyticsService.trackedErrors).containsExactly(
|
||||
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ dependencies {
|
|||
implementation(projects.features.deactivation.api)
|
||||
implementation(projects.features.roomlist.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.datetime)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.compound.theme.Theme
|
|||
sealed interface AdvancedSettingsEvents {
|
||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents
|
||||
data object ChangeTheme : AdvancedSettingsEvents
|
||||
data object CancelChangeTheme : AdvancedSettingsEvents
|
||||
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ class AdvancedSettingsPresenter @Inject constructor(
|
|||
val isSharePresenceEnabled by sessionPreferencesStore
|
||||
.isSharePresenceEnabled()
|
||||
.collectAsState(initial = true)
|
||||
val doesCompressMedia by sessionPreferencesStore
|
||||
.doesCompressMedia()
|
||||
.collectAsState(initial = false)
|
||||
val theme by remember {
|
||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||
}
|
||||
|
|
@ -49,6 +52,9 @@ class AdvancedSettingsPresenter @Inject constructor(
|
|||
is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch {
|
||||
sessionPreferencesStore.setSharePresence(event.enabled)
|
||||
}
|
||||
is AdvancedSettingsEvents.SetCompressMedia -> localCoroutineScope.launch {
|
||||
sessionPreferencesStore.setCompressMedia(event.compress)
|
||||
}
|
||||
AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false
|
||||
AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true
|
||||
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch {
|
||||
|
|
@ -61,6 +67,7 @@ class AdvancedSettingsPresenter @Inject constructor(
|
|||
return AdvancedSettingsState(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||
doesCompressMedia = doesCompressMedia,
|
||||
theme = theme,
|
||||
showChangeThemeDialog = showChangeThemeDialog,
|
||||
eventSink = { handleEvents(it) }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.compound.theme.Theme
|
|||
data class AdvancedSettingsState(
|
||||
val isDeveloperModeEnabled: Boolean,
|
||||
val isSharePresenceEnabled: Boolean,
|
||||
val doesCompressMedia: Boolean,
|
||||
val theme: Theme,
|
||||
val showChangeThemeDialog: Boolean,
|
||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||
|
|
|
|||
|
|
@ -16,18 +16,21 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
|||
aAdvancedSettingsState(),
|
||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||
aAdvancedSettingsState(showChangeThemeDialog = true),
|
||||
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
|
||||
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
||||
aAdvancedSettingsState(doesCompressMedia = true),
|
||||
)
|
||||
}
|
||||
|
||||
fun aAdvancedSettingsState(
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
isSendPublicReadReceiptsEnabled: Boolean = false,
|
||||
isSharePresenceEnabled: Boolean = false,
|
||||
doesCompressMedia: Boolean = false,
|
||||
showChangeThemeDialog: Boolean = false,
|
||||
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
||||
) = AdvancedSettingsState(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
|
||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||
doesCompressMedia = doesCompressMedia,
|
||||
theme = Theme.System,
|
||||
showChangeThemeDialog = showChangeThemeDialog,
|
||||
eventSink = eventSink
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.themes
|
||||
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.Text
|
||||
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.toImmutableList
|
||||
|
||||
|
|
@ -32,6 +35,7 @@ fun AdvancedSettingsView(
|
|||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val analyticsService = LocalAnalyticsService.current
|
||||
PreferencePage(
|
||||
modifier = modifier,
|
||||
onBackClick = onBackClick,
|
||||
|
|
@ -72,6 +76,28 @@ fun AdvancedSettingsView(
|
|||
),
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -138,8 +138,8 @@ private fun ColumnScope.ManageAppSection(
|
|||
}
|
||||
if (state.showSecureBackup) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())),
|
||||
headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
|
||||
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
|
||||
onClick = onSecureBackupClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class AdvancedSettingsPresenterTest {
|
|||
assertThat(initialState.isDeveloperModeEnabled).isFalse()
|
||||
assertThat(initialState.showChangeThemeDialog).isFalse()
|
||||
assertThat(initialState.isSharePresenceEnabled).isTrue()
|
||||
assertThat(initialState.doesCompressMedia).isFalse()
|
||||
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
|
||||
fun `present - change theme`() = runTest {
|
||||
val presenter = createAdvancedSettingsPresenter()
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@
|
|||
package io.element.android.features.preferences.impl.advanced
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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.features.preferences.impl.R
|
||||
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.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
|
|
@ -91,16 +97,64 @@ class AdvancedSettingsViewTest {
|
|||
rule.clickOn(R.string.screen_advanced_settings_share_presence)
|
||||
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(
|
||||
state: AdvancedSettingsState,
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
AdvancedSettingsView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalAnalyticsService provides analyticsService,
|
||||
) {
|
||||
AdvancedSettingsView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ internal fun SetUpRecoveryKeyBanner(
|
|||
modifier = modifier,
|
||||
title = stringResource(R.string.banner_set_up_recovery_title),
|
||||
content = stringResource(R.string.banner_set_up_recovery_content),
|
||||
actionText = stringResource(R.string.banner_set_up_recovery_submit),
|
||||
onSubmitClick = onContinueClick,
|
||||
onDismissClick = onDismissClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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_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_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_title">"Set up recovery"</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_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_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>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class RoomListPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAvatarIndicator).isTrue()
|
||||
sessionVerificationService.givenNeedsSessionVerification(false)
|
||||
sessionVerificationService.emitNeedsSessionVerification(false)
|
||||
encryptionService.emitBackupState(BackupState.ENABLED)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.showAvatarIndicator).isFalse()
|
||||
|
|
@ -231,7 +231,7 @@ class RoomListPresenterTest {
|
|||
roomListService = roomListService,
|
||||
encryptionService = encryptionService,
|
||||
sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
emitNeedsSessionVerification(false)
|
||||
},
|
||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -121,12 +121,9 @@ class RoomListViewTest {
|
|||
),
|
||||
onSetUpRecoveryClick = callback,
|
||||
)
|
||||
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
|
||||
rule.clickOn(R.string.banner_set_up_recovery_submit)
|
||||
eventsRecorder.assertEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import dagger.assisted.AssistedInject
|
|||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
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.reset.ResetIdentityFlowNode
|
||||
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
|
||||
|
|
@ -63,9 +62,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data object Disable : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Enable : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
||||
|
|
@ -91,10 +87,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.Disable)
|
||||
}
|
||||
|
||||
override fun onEnableClick() {
|
||||
backstack.push(NavTarget.Enable)
|
||||
}
|
||||
|
||||
override fun onConfirmRecoveryKeyClick() {
|
||||
backstack.push(NavTarget.EnterRecoveryKey)
|
||||
}
|
||||
|
|
@ -116,9 +108,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
NavTarget.Disable -> {
|
||||
createNode<SecureBackupDisableNode>(buildContext)
|
||||
}
|
||||
NavTarget.Enable -> {
|
||||
createNode<SecureBackupEnableNode>(buildContext)
|
||||
}
|
||||
NavTarget.EnterRecoveryKey -> {
|
||||
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
|
||||
override fun onEnterRecoveryKeySuccess() {
|
||||
|
|
|
|||
|
|
@ -37,11 +37,7 @@ class SecureBackupDisablePresenter @Inject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
fun handleEvents(event: SecureBackupDisableEvents) {
|
||||
when (event) {
|
||||
is SecureBackupDisableEvents.DisableBackup -> if (disableAction.value.isConfirming()) {
|
||||
coroutineScope.disableBackup(disableAction)
|
||||
} else {
|
||||
disableAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
is SecureBackupDisableEvents.DisableBackup -> coroutineScope.disableBackup(disableAction)
|
||||
SecureBackupDisableEvents.DismissDialogs -> {
|
||||
disableAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.features.securebackup.impl.disable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.components.BigIcon
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
|
|
@ -44,7 +44,7 @@ fun SecureBackupDisableView(
|
|||
onBackClick = onBackClick,
|
||||
title = stringResource(id = R.string.screen_key_backup_disable_title),
|
||||
subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()),
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
buttons = { Buttons(state = state) },
|
||||
) {
|
||||
Content(state = state)
|
||||
|
|
@ -52,12 +52,6 @@ fun SecureBackupDisableView(
|
|||
|
||||
AsyncActionView(
|
||||
async = state.disableAction,
|
||||
confirmationDialog = {
|
||||
SecureBackupDisableConfirmationDialog(
|
||||
onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) },
|
||||
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
|
||||
)
|
||||
},
|
||||
progressDialog = {},
|
||||
errorMessage = { it.message ?: it.toString() },
|
||||
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
|
||||
private fun ColumnScope.Buttons(
|
||||
state: SecureBackupDisableState,
|
||||
|
|
@ -105,15 +87,20 @@ private fun Content(state: SecureBackupDisableState) {
|
|||
|
||||
@Composable
|
||||
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(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
|
||||
text = text,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -9,4 +9,7 @@ package io.element.android.features.securebackup.impl.root
|
|||
|
||||
sealed interface SecureBackupRootEvents {
|
||||
data object RetryKeyBackupState : SecureBackupRootEvents
|
||||
data object EnableKeyStorage : SecureBackupRootEvents
|
||||
data object DisplayKeyStorageDisabledError : SecureBackupRootEvents
|
||||
data object DismissDialog : SecureBackupRootEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ class SecureBackupRootNode @AssistedInject constructor(
|
|||
fun onSetupClick()
|
||||
fun onChangeClick()
|
||||
fun onDisableClick()
|
||||
fun onEnableClick()
|
||||
fun onConfirmRecoveryKeyClick()
|
||||
}
|
||||
|
||||
|
|
@ -50,10 +49,6 @@ class SecureBackupRootNode @AssistedInject constructor(
|
|||
plugins<Callback>().forEach { it.onDisableClick() }
|
||||
}
|
||||
|
||||
private fun onEnableClick() {
|
||||
plugins<Callback>().forEach { it.onEnableClick() }
|
||||
}
|
||||
|
||||
private fun onConfirmRecoveryKeyClick() {
|
||||
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() }
|
||||
}
|
||||
|
|
@ -71,7 +66,6 @@ class SecureBackupRootNode @AssistedInject constructor(
|
|||
onBackClick = ::navigateUp,
|
||||
onSetupClick = ::onSetupClick,
|
||||
onChangeClick = ::onChangeClick,
|
||||
onEnableClick = ::onEnableClick,
|
||||
onDisableClick = ::onDisableClick,
|
||||
onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick,
|
||||
onLearnMoreClick = { onLearnMoreClick(uriHandler) },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
|
|
@ -41,7 +44,8 @@ class SecureBackupRootPresenter @Inject constructor(
|
|||
|
||||
val backupState by encryptionService.backupStateStateFlow.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("recoveryState: $recoveryState")
|
||||
|
||||
|
|
@ -56,14 +60,22 @@ class SecureBackupRootPresenter @Inject constructor(
|
|||
fun handleEvents(event: SecureBackupRootEvents) {
|
||||
when (event) {
|
||||
SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction)
|
||||
SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction)
|
||||
SecureBackupRootEvents.DismissDialog -> {
|
||||
enableAction.value = AsyncAction.Uninitialized
|
||||
displayKeyStorageDisabledError = false
|
||||
}
|
||||
SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true
|
||||
}
|
||||
}
|
||||
|
||||
return SecureBackupRootState(
|
||||
enableAction = enableAction.value,
|
||||
backupState = backupState,
|
||||
doesBackupExistOnServer = doesBackupExistOnServerAction.value,
|
||||
recoveryState = recoveryState,
|
||||
appName = buildMeta.applicationName,
|
||||
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
|
|
@ -74,4 +86,11 @@ class SecureBackupRootPresenter @Inject constructor(
|
|||
encryptionService.doesBackupExistOnServer().getOrThrow()
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,31 @@
|
|||
|
||||
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.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
|
||||
data class SecureBackupRootState(
|
||||
val enableAction: AsyncAction<Unit>,
|
||||
val backupState: BackupState,
|
||||
val doesBackupExistOnServer: AsyncData<Boolean>,
|
||||
val recoveryState: RecoveryState,
|
||||
val appName: String,
|
||||
val displayKeyStorageDisabledError: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.securebackup.impl.root
|
||||
|
||||
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.designsystem.utils.snackbar.SnackbarMessage
|
||||
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.WAITING_FOR_SYNC),
|
||||
aSecureBackupRootState(backupState = BackupState.CREATING),
|
||||
aSecureBackupRootState(
|
||||
backupState = BackupState.CREATING,
|
||||
enableAction = AsyncAction.Failure(Exception("Error")),
|
||||
),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLING),
|
||||
aSecureBackupRootState(backupState = BackupState.RESUMING),
|
||||
aSecureBackupRootState(backupState = BackupState.DOWNLOADING),
|
||||
aSecureBackupRootState(backupState = BackupState.DISABLING),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE),
|
||||
// Add other states here
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.UNKNOWN),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.ENABLED),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.DISABLED),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.INCOMPLETE),
|
||||
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(
|
||||
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
backupState: BackupState = BackupState.UNKNOWN,
|
||||
doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized,
|
||||
recoveryState: RecoveryState = RecoveryState.UNKNOWN,
|
||||
displayKeyStorageDisabledError: Boolean = false,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
) = SecureBackupRootState(
|
||||
enableAction = enableAction,
|
||||
backupState = backupState,
|
||||
doesBackupExistOnServer = doesBackupExistOnServer,
|
||||
recoveryState = recoveryState,
|
||||
appName = "Element",
|
||||
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,28 +7,27 @@
|
|||
|
||||
package io.element.android.features.securebackup.impl.root
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.progressSemantics
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
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.preferences.PreferenceDivider
|
||||
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.PreviewsDayNight
|
||||
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.HorizontalDivider
|
||||
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.TextButton
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
|
|
@ -41,7 +40,6 @@ fun SecureBackupRootView(
|
|||
onBackClick: () -> Unit,
|
||||
onSetupClick: () -> Unit,
|
||||
onChangeClick: () -> Unit,
|
||||
onEnableClick: () -> Unit,
|
||||
onDisableClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
|
|
@ -52,122 +50,186 @@ fun SecureBackupRootView(
|
|||
PreferencePage(
|
||||
modifier = modifier,
|
||||
onBackClick = onBackClick,
|
||||
title = stringResource(id = CommonStrings.common_chat_backup),
|
||||
title = stringResource(id = CommonStrings.common_encryption),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
fullTextRes = R.string.screen_chat_backup_key_backup_description,
|
||||
coloredTextRes = CommonStrings.action_learn_more,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
underline = false,
|
||||
bold = true,
|
||||
)
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_key_backup_title),
|
||||
subtitleAnnotated = text,
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_chat_backup_key_backup_title),
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = buildAnnotatedStringWithStyledPart(
|
||||
fullTextRes = R.string.screen_chat_backup_key_backup_description,
|
||||
coloredTextRes = CommonStrings.action_learn_more,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
underline = false,
|
||||
bold = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClick = onLearnMoreClick,
|
||||
)
|
||||
|
||||
// Disable / Enable backup
|
||||
when (state.backupState) {
|
||||
BackupState.WAITING_FOR_SYNC -> Unit
|
||||
BackupState.UNKNOWN -> {
|
||||
when (state.doesBackupExistOnServer) {
|
||||
is AsyncData.Success -> when (state.doesBackupExistOnServer.data) {
|
||||
true -> {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
|
||||
tintColor = ElementTheme.colors.textCriticalPrimary,
|
||||
onClick = onDisableClick,
|
||||
// Disable / Enable key storage
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title),
|
||||
)
|
||||
},
|
||||
trailingContent = when (state.backupState) {
|
||||
BackupState.WAITING_FOR_SYNC,
|
||||
BackupState.DISABLING -> ListItemContent.Custom { LoadingView() }
|
||||
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.ENABLING,
|
||||
BackupState.RESUMING,
|
||||
BackupState.ENABLED,
|
||||
BackupState.DOWNLOADING -> {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
|
||||
tintColor = ElementTheme.colors.textCriticalPrimary,
|
||||
onClick = onDisableClick,
|
||||
)
|
||||
}
|
||||
BackupState.DISABLING -> {
|
||||
AsyncLoading()
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceDivider()
|
||||
|
||||
BackupState.CREATING,
|
||||
BackupState.ENABLING,
|
||||
BackupState.RESUMING,
|
||||
BackupState.ENABLED,
|
||||
BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true)
|
||||
},
|
||||
onClick = {
|
||||
when (state.backupState) {
|
||||
BackupState.WAITING_FOR_SYNC,
|
||||
BackupState.DISABLING -> Unit
|
||||
BackupState.UNKNOWN -> {
|
||||
when (state.doesBackupExistOnServer) {
|
||||
is AsyncData.Success -> {
|
||||
if (state.doesBackupExistOnServer.data) {
|
||||
onDisableClick()
|
||||
} else {
|
||||
state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage)
|
||||
}
|
||||
}
|
||||
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
|
||||
when (state.recoveryState) {
|
||||
RecoveryState.UNKNOWN,
|
||||
RecoveryState.WAITING_FOR_SYNC -> Unit
|
||||
RecoveryState.DISABLED -> {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
|
||||
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
|
||||
onClick = onSetupClick,
|
||||
showEndBadge = true,
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
|
||||
)
|
||||
},
|
||||
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 -> {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
|
||||
onClick = onChangeClick,
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
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 ->
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
|
||||
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
|
||||
showEndBadge = true,
|
||||
onClick = onConfirmRecoveryKeyClick,
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
|
||||
)
|
||||
},
|
||||
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
|
||||
|
|
@ -180,7 +242,6 @@ internal fun SecureBackupRootViewPreview(
|
|||
onBackClick = {},
|
||||
onSetupClick = {},
|
||||
onChangeClick = {},
|
||||
onEnableClick = {},
|
||||
onDisableClick = {},
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
onLearnMoreClick = {},
|
||||
|
|
|
|||
|
|
@ -91,14 +91,14 @@ private fun RecoveryKeyStaticContent(
|
|||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.clickableIfNotNull(onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.clickableIfNotNull(onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (state.formattedRecoveryKey != null) {
|
||||
|
|
@ -116,15 +116,15 @@ private fun RecoveryKeyStaticContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 11.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 11.dp)
|
||||
) {
|
||||
if (state.inProgress) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.progressSemantics()
|
||||
.padding(end = 8.dp)
|
||||
.size(16.dp),
|
||||
.progressSemantics()
|
||||
.padding(end = 8.dp)
|
||||
.size(16.dp),
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
|
|
@ -161,12 +161,12 @@ private fun RecoveryKeyFormContent(
|
|||
}
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.recoveryKey)
|
||||
.autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { onChange(it) },
|
||||
),
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.recoveryKey)
|
||||
.autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { onChange(it) },
|
||||
),
|
||||
minLines = 2,
|
||||
value = state.formattedRecoveryKey.orEmpty(),
|
||||
onValueChange = onChange,
|
||||
|
|
@ -189,30 +189,18 @@ private fun RecoveryKeyFooter(state: RecoveryKeyViewState) {
|
|||
RecoveryKeyUserStory.Setup,
|
||||
RecoveryKeyUserStory.Change -> {
|
||||
if (state.formattedRecoveryKey == null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.InfoSolid(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
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 = 16.dp),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_recovery_key_save_key_description),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_title">"Allow key storage"</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_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_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_point_1">"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_title">"Are you sure you want to turn off backup?"</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">"You will not have encrypted message history on new devices"</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 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_generate_key">"Generate a new recovery key"</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_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_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_description">"Do not share this with anyone!"</string>
|
||||
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
fun `present - user delete backup success`() = runTest {
|
||||
val presenter = createSecureBackupDisablePresenter()
|
||||
|
|
@ -63,9 +47,6 @@ class SecureBackupDisablePresenterTest {
|
|||
val initialState = awaitItem()
|
||||
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
|
||||
val state = awaitItem()
|
||||
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
val finalState = awaitItem()
|
||||
|
|
@ -87,9 +68,6 @@ class SecureBackupDisablePresenterTest {
|
|||
val initialState = awaitItem()
|
||||
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
|
||||
val state = awaitItem()
|
||||
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
val errorState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ 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.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
|
|
@ -38,6 +39,8 @@ class SecureBackupRootPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
|
||||
assertThat(initialState.doesBackupExistOnServer.dataOrNull()).isTrue()
|
||||
assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
assertThat(initialState.displayKeyStorageDisabledError).isFalse()
|
||||
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
|
||||
assertThat(initialState.appName).isEqualTo("Element")
|
||||
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(
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
appName: String = "Element",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ dependencies {
|
|||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ class SharePresenter @AssistedInject constructor(
|
|||
private val shareIntentHandler: ShareIntentHandler,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
) : Presenter<ShareState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -71,13 +73,16 @@ class SharePresenter @AssistedInject constructor(
|
|||
roomIds
|
||||
.map { roomId ->
|
||||
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
|
||||
.map { fileToShare ->
|
||||
mediaSender.sendMedia(
|
||||
uri = fileToShare.uri,
|
||||
mimeType = fileToShare.mimeType,
|
||||
compressIfPossible = true,
|
||||
).isSuccess
|
||||
}
|
||||
.all { it }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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_ROOM_ID
|
||||
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.mediaupload.api.MediaPreProcessor
|
||||
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.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SharePresenterTest {
|
||||
|
|
@ -111,8 +116,11 @@ class SharePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media ok`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val matrixRoom = FakeMatrixRoom(
|
||||
sendMediaResult = { Result.success(FakeMediaUploadHandler()) },
|
||||
sendFileResult = sendFileResult,
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, matrixRoom)
|
||||
|
|
@ -140,6 +148,7 @@ class SharePresenterTest {
|
|||
val success = awaitItem()
|
||||
assertThat(success.shareAction.isSuccess()).isTrue()
|
||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +163,8 @@ class SharePresenterTest {
|
|||
appCoroutineScope = this,
|
||||
shareIntentHandler = shareIntentHandler,
|
||||
matrixClient = matrixClient,
|
||||
mediaPreProcessor = mediaPreProcessor
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
InMemorySessionPreferencesStore(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ fun UserProfileView(
|
|||
private fun VerifyUserSection(state: UserProfileState) {
|
||||
if (state.isVerified.dataOrNull() == false) {
|
||||
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)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
|
||||
enabled = false,
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ dependencies {
|
|||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
|
@ -43,6 +44,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* 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.node.Node
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* 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 androidx.compose.runtime.Composable
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
@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.LaunchedEffect
|
||||
|
|
@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
import timber.log.Timber
|
||||
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(
|
||||
@Assisted private val showDeviceVerifiedScreen: Boolean,
|
||||
|
|
@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// 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 stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
|
|
@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
val signOutAction = remember {
|
||||
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val verificationFlowStep by remember {
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
if (skipVerification) {
|
||||
VerifySelfSessionState.VerificationStep.Skipped
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
} else {
|
||||
when (sessionVerifiedStatus) {
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||
SessionVerifiedStatus.NotVerified -> {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
||||
|
|
@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
SessionVerifiedStatus.Verified -> {
|
||||
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
||||
// The user has verified the session, we need to show the success screen
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
} else {
|
||||
// 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) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||
|
|
@ -115,7 +117,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
return VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
step = step,
|
||||
signOutAction = signOutAction.value,
|
||||
displaySkipButton = buildMeta.isDebuggable,
|
||||
eventSink = ::handleEvents,
|
||||
|
|
@ -124,10 +126,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
canEnterRecoveryKey: Boolean
|
||||
): VerifySelfSessionState.VerificationStep =
|
||||
): VerifySelfSessionState.Step =
|
||||
when (val machineState = this) {
|
||||
StateMachineState.Initial, null -> {
|
||||
VerifySelfSessionState.VerificationStep.Initial(
|
||||
VerifySelfSessionState.Step.Initial(
|
||||
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||
isLastDevice = encryptionService.isLastDevice.value
|
||||
)
|
||||
|
|
@ -136,15 +138,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted,
|
||||
StateMachineState.Canceling -> {
|
||||
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
|
||||
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
||||
}
|
||||
|
||||
StateMachineState.VerificationRequestAccepted -> {
|
||||
VerifySelfSessionState.VerificationStep.Ready
|
||||
VerifySelfSessionState.Step.Ready
|
||||
}
|
||||
|
||||
StateMachineState.Canceled -> {
|
||||
VerifySelfSessionState.VerificationStep.Canceled
|
||||
VerifySelfSessionState.Step.Canceled
|
||||
}
|
||||
|
||||
is StateMachineState.Verifying -> {
|
||||
|
|
@ -152,38 +154,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
|
||||
else -> AsyncData.Uninitialized
|
||||
}
|
||||
VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async)
|
||||
VerifySelfSessionState.Step.Verifying(machineState.data, async)
|
||||
}
|
||||
|
||||
StateMachineState.Completed -> {
|
||||
VerifySelfSessionState.VerificationStep.Completed
|
||||
VerifySelfSessionState.Step.Completed
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.AcceptedVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.StartedSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.ReceivedVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.Finished -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.Canceled -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.Failed -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
sessionVerificationService.verificationFlowState
|
||||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||
.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.DidAcceptVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.DidStartSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.DidCancel -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
}
|
||||
VerificationFlowState.DidFail -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.launchIn(this)
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* 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.Stable
|
||||
|
|
@ -15,22 +15,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
|
|||
|
||||
@Immutable
|
||||
data class VerifySelfSessionState(
|
||||
val verificationFlowStep: VerificationStep,
|
||||
val step: Step,
|
||||
val signOutAction: AsyncAction<String?>,
|
||||
val displaySkipButton: Boolean,
|
||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface VerificationStep {
|
||||
data object Loading : VerificationStep
|
||||
sealed interface Step {
|
||||
data object Loading : Step
|
||||
|
||||
// FIXME canEnterRecoveryKey value is never read.
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
|
||||
data object Canceled : VerificationStep
|
||||
data object AwaitingOtherDeviceResponse : VerificationStep
|
||||
data object Ready : VerificationStep
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
|
||||
data object Completed : VerificationStep
|
||||
data object Skipped : VerificationStep
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
|
||||
data object Canceled : Step
|
||||
data object AwaitingOtherDeviceResponse : Step
|
||||
data object Ready : Step
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
||||
data object Completed : Step
|
||||
data object Skipped : Step
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@
|
|||
@file:Suppress("WildcardImport")
|
||||
@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 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.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
|
|
@ -37,10 +39,10 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RequestingVerification> {
|
||||
|
|
@ -48,7 +50,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
sessionVerificationService.requestVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptVerificationRequest, state ->
|
||||
state.override { State.VerificationRequestAccepted }
|
||||
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.StartingSasVerification> {
|
||||
|
|
@ -58,28 +60,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
}
|
||||
inState<State.VerificationRequestAccepted> {
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
state.override { State.StartingSasVerification }
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceled> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification }
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Reset, state ->
|
||||
state.override { State.Initial }
|
||||
state.override { State.Initial.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.SasVerificationStarted> {
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
state.override { State.Verifying.ChallengeReceived(event.data) }
|
||||
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.ChallengeReceived> {
|
||||
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 ->
|
||||
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> {
|
||||
|
|
@ -100,7 +102,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
.first()
|
||||
}
|
||||
}
|
||||
state.override { State.Completed }
|
||||
state.override { State.Completed.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceling> {
|
||||
|
|
@ -110,8 +112,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
}
|
||||
}
|
||||
inState {
|
||||
logReceivedEvents()
|
||||
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
|
||||
state.override { State.SasVerificationStarted }
|
||||
state.override { State.SasVerificationStarted.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
|
|
@ -120,17 +123,17 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
// `Canceling` state to `Canceled` automatically anymore
|
||||
else -> {
|
||||
sessionVerificationService.cancelVerification()
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
state.override { State.Canceled }
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RequestingVerification -> state.override { State.Initial }
|
||||
else -> state.override { State.Canceled }
|
||||
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
|
||||
else -> state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -5,25 +5,19 @@
|
|||
* 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.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -31,18 +25,17 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
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.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.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.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.components.BigIcon
|
||||
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.TopAppBar
|
||||
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.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -71,12 +62,13 @@ fun VerifySelfSessionView(
|
|||
onSuccessLogout: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step = state.step
|
||||
fun cancelOrResetFlow() {
|
||||
when (state.verificationFlowStep) {
|
||||
is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is FlowStep.Verifying -> {
|
||||
if (!state.verificationFlowStep.state.isLoading()) {
|
||||
when (step) {
|
||||
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is Step.Verifying -> {
|
||||
if (!step.state.isLoading()) {
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
}
|
||||
}
|
||||
|
|
@ -85,18 +77,17 @@ fun VerifySelfSessionView(
|
|||
}
|
||||
|
||||
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
|
||||
LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
|
||||
if (state.verificationFlowStep is FlowStep.Skipped) {
|
||||
LaunchedEffect(step, latestOnFinish) {
|
||||
if (step is Step.Skipped) {
|
||||
latestOnFinish()
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
cancelOrResetFlow()
|
||||
}
|
||||
val verificationFlowStep = state.verificationFlowStep
|
||||
|
||||
if (state.verificationFlowStep is FlowStep.Loading ||
|
||||
state.verificationFlowStep is FlowStep.Skipped) {
|
||||
if (step is Step.Loading ||
|
||||
step is Step.Skipped) {
|
||||
// Just display a loader in this case, to avoid UI glitch.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -111,7 +102,7 @@ fun VerifySelfSessionView(
|
|||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
if (state.verificationFlowStep !is FlowStep.Completed &&
|
||||
if (step !is Step.Completed &&
|
||||
state.displaySkipButton &&
|
||||
LocalInspectionMode.current.not()) {
|
||||
TextButton(
|
||||
|
|
@ -119,7 +110,7 @@ fun VerifySelfSessionView(
|
|||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
|
||||
)
|
||||
}
|
||||
if (state.verificationFlowStep is FlowStep.Initial) {
|
||||
if (step is Step.Initial) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_signout),
|
||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
|
||||
|
|
@ -129,10 +120,10 @@ fun VerifySelfSessionView(
|
|||
)
|
||||
},
|
||||
header = {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
VerifySelfSessionHeader(step = step)
|
||||
},
|
||||
footer = {
|
||||
BottomMenu(
|
||||
VerifySelfSessionBottomMenu(
|
||||
screenState = state,
|
||||
onCancelClick = ::cancelOrResetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
|
|
@ -141,8 +132,8 @@ fun VerifySelfSessionView(
|
|||
)
|
||||
}
|
||||
) {
|
||||
Content(
|
||||
flowState = verificationFlowStep,
|
||||
VerifySelfSessionContent(
|
||||
flowState = step,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -165,38 +156,38 @@ fun VerifySelfSessionView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
val iconStyle = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
FlowStep.Canceled -> BigIcon.Style.AlertSolid
|
||||
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
FlowStep.Completed -> BigIcon.Style.SuccessSolid
|
||||
is FlowStep.Skipped -> return
|
||||
private fun VerifySelfSessionHeader(step: Step) {
|
||||
val iconStyle = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val titleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
FlowStep.Canceled -> CommonStrings.common_verification_cancelled
|
||||
FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_title
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val titleTextId = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
Step.Canceled -> CommonStrings.common_verification_cancelled
|
||||
Step.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||
Step.Completed -> R.string.screen_identity_confirmed_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
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
val subtitleTextId = when (verificationFlowStep) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
val subtitleTextId = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
Step.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
Step.Completed -> R.string.screen_identity_confirmed_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
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
|
||||
PageTitle(
|
||||
|
|
@ -207,16 +198,16 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
flowState: FlowStep,
|
||||
private fun VerifySelfSessionContent(
|
||||
flowState: Step,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
) {
|
||||
when (flowState) {
|
||||
is VerifySelfSessionState.VerificationStep.Initial -> {
|
||||
is Step.Initial -> {
|
||||
ContentInitial(onLearnMoreClick)
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
ContentVerifying(flowState)
|
||||
is Step.Verifying -> {
|
||||
VerificationContentVerifying(flowState.data)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -241,79 +232,22 @@ private fun ContentInitial(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
|
||||
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(
|
||||
private fun VerifySelfSessionBottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
) {
|
||||
val verificationViewState = screenState.verificationFlowStep
|
||||
val verificationViewState = screenState.step
|
||||
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) {
|
||||
VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
|
||||
is FlowStep.Initial -> {
|
||||
BottomMenu {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> {
|
||||
VerificationBottomMenu {
|
||||
if (verificationViewState.isLastDevice) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -340,8 +274,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Canceled -> {
|
||||
BottomMenu {
|
||||
is Step.Canceled -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_canceled),
|
||||
|
|
@ -354,8 +288,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Ready -> {
|
||||
BottomMenu {
|
||||
is Step.Ready -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_start),
|
||||
|
|
@ -368,8 +302,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.AwaitingOtherDeviceResponse -> {
|
||||
BottomMenu {
|
||||
is Step.AwaitingOtherDeviceResponse -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
|
|
@ -380,13 +314,13 @@ private fun BottomMenu(
|
|||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
is Step.Verifying -> {
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
||||
} else {
|
||||
stringResource(R.string.screen_session_verification_they_match)
|
||||
}
|
||||
BottomMenu {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
|
|
@ -404,8 +338,8 @@ private fun BottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Completed -> {
|
||||
BottomMenu {
|
||||
is Step.Completed -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
|
|
@ -415,19 +349,7 @@ private fun BottomMenu(
|
|||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
buttons: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
buttons()
|
||||
is Step.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* 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 {
|
||||
data object RequestVerification : VerifySelfSessionViewEvents
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* 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.moleculeFlow
|
||||
|
|
@ -14,12 +14,13 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.logout.api.LogoutUseCase
|
||||
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.core.meta.BuildMeta
|
||||
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.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.SessionVerifiedStatus
|
||||
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.preferences.test.InMemorySessionPreferencesStore
|
||||
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -43,12 +45,14 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Initial state is received`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().run {
|
||||
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(step).isEqualTo(Step.Initial(false))
|
||||
assertThat(displaySkipButton).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +61,10 @@ class VerifySelfSessionPresenterTest {
|
|||
@Test
|
||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
||||
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -67,7 +74,11 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key`() = runTest {
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(
|
||||
resetLambda = resetLambda
|
||||
),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
}
|
||||
|
|
@ -75,13 +86,15 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
|
||||
resetLambda.assertions().isCalledOnce().with(value(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitIsLastDevice(true)
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
|
|
@ -90,13 +103,16 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Handles requestVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -107,32 +123,36 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - Handles startSasVerification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response:
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
// ChallengeReceived:
|
||||
service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
val verifyingState = awaitItem()
|
||||
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Cancelation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter()
|
||||
fun `present - Cancellation on initial state does nothing`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
|
|
@ -141,92 +161,110 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.shouldFail = true
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
// Cancelling
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
// Cancelled
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
service.shouldFail = true
|
||||
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
service.shouldFail = false
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Restart after cancellation returns to requesting verification`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Went back to requesting verification
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Go back after cancelation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
service.givenVerificationFlowState(VerificationFlowState.Canceled)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
// Went back to initial state
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +274,11 @@ class VerifySelfSessionPresenterTest {
|
|||
val emojis = listOf(
|
||||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||
)
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -246,54 +288,65 @@ class VerifySelfSessionPresenterTest {
|
|||
SessionVerificationData.Emojis(emojis)
|
||||
)
|
||||
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emojis),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is declined, the flow is canceled`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
declineVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(
|
||||
VerificationStep.Verifying(
|
||||
assertThat(awaitItem().step).isEqualTo(
|
||||
Step.Verifying(
|
||||
SessionVerificationData.Emojis(emptyList()),
|
||||
AsyncData.Loading(),
|
||||
)
|
||||
)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Skip event skips the flow`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
|
|
@ -302,16 +355,18 @@ class VerifySelfSessionPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is not needed, the flow is skipped`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
|
|
@ -321,16 +376,18 @@ class VerifySelfSessionPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
val service = FakeSessionVerificationService(
|
||||
resetLambda = { },
|
||||
).apply {
|
||||
emitNeedsSessionVerification(false)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
||||
}
|
||||
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
|
|
@ -356,33 +413,53 @@ class VerifySelfSessionPresenterTest {
|
|||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||
): VerifySelfSessionState {
|
||||
var state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(state.step).isEqualTo(Step.Initial(false))
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Await for the state to be Ready
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
|
||||
assertThat(state.step).isEqualTo(Step.Ready)
|
||||
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response (again):
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
|
||||
fakeService.triggerReceiveVerificationData(sessionVerificationData)
|
||||
assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||
// Finally, ChallengeReceived:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
|
||||
state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
|
||||
assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
|
||||
return state
|
||||
}
|
||||
|
||||
private fun unverifiedSessionService(): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
private suspend fun unverifiedSessionService(
|
||||
requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
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(
|
||||
service: SessionVerificationService = unverifiedSessionService(),
|
||||
service: SessionVerificationService,
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
|
|
@ -5,12 +5,14 @@
|
|||
* 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.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.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
step = VerifySelfSessionState.Step.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
|
||||
step = VerifySelfSessionState.Step.Ready,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Loading(),
|
||||
),
|
||||
|
|
@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
|
|
@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onFinished = callback,
|
||||
|
|
@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
|
|
@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
step = VerifySelfSessionState.Step.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onLearnMoreClick = callback,
|
||||
|
|
@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
step = VerifySelfSessionState.Step.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
|
|
@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
|
|||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
|
||||
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
|
||||
displaySkipButton = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
|
|
@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
|
|||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
|
||||
step = VerifySelfSessionState.Step.Skipped,
|
||||
displaySkipButton = true,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
[versions]
|
||||
# Project
|
||||
android_gradle_plugin = "8.7.1"
|
||||
kotlin = "2.0.20"
|
||||
kotlin = "2.0.21"
|
||||
kotlinpoet = "2.0.0"
|
||||
ksp = "2.0.20-1.0.25"
|
||||
ksp = "2.0.21-1.0.26"
|
||||
firebaseAppDistribution = "5.0.0"
|
||||
|
||||
# AndroidX
|
||||
|
|
@ -22,14 +22,14 @@ constraintlayout_compose = "1.0.1"
|
|||
lifecycle = "2.8.6"
|
||||
activity = "1.9.3"
|
||||
media3 = "1.4.1"
|
||||
camera = "1.3.4"
|
||||
camera = "1.4.0"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2024.10.00"
|
||||
composecompiler = "1.5.15"
|
||||
|
||||
# Coroutines
|
||||
coroutines = "1.8.1"
|
||||
coroutines = "1.9.0"
|
||||
|
||||
# Accompanist
|
||||
accompanist = "0.36.0"
|
||||
|
|
@ -37,17 +37,21 @@ accompanist = "0.36.0"
|
|||
# Test
|
||||
test_core = "1.6.1"
|
||||
|
||||
# Jetbrain
|
||||
datetime = "0.6.1"
|
||||
serialization_json = "1.7.3"
|
||||
|
||||
#other
|
||||
coil = "2.7.0"
|
||||
datetime = "0.6.0"
|
||||
dependencyAnalysis = "2.3.0"
|
||||
serialization_json = "1.6.3"
|
||||
showkase = "1.0.3"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.2"
|
||||
wysiwyg = "2.37.13"
|
||||
telephoto = "0.13.0"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "2.4.0"
|
||||
|
||||
# DI
|
||||
dagger = "2.52"
|
||||
anvil = "0.3.3"
|
||||
|
|
@ -81,7 +85,7 @@ ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version
|
|||
# AndroidX
|
||||
androidx_core = { module = "androidx.core:core", 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_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
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" }
|
||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||
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_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
|
||||
jsoup = "org.jsoup:jsoup:1.18.1"
|
||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
|
||||
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_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
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"
|
||||
maplibre = "org.maplibre.gl:android-sdk:11.5.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"
|
||||
zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
|
||||
|
||||
# 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"
|
||||
# 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
|
||||
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.libraries.core.coroutine
|
||||
|
||||
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
|
@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||
* 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].
|
||||
*/
|
||||
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
|
||||
class DerivedStateFlow<T>(
|
||||
private val getValue: () -> T,
|
||||
private val flow: Flow<T>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
|
|||
|
||||
const val A_FORMATTED_DATE = "formatted_date"
|
||||
|
||||
class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter {
|
||||
private var format = ""
|
||||
class FakeLastMessageTimestampFormatter(
|
||||
var format: String = "",
|
||||
) : LastMessageTimestampFormatter {
|
||||
fun givenFormat(format: String) {
|
||||
this.format = format
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
|
|||
* @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 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.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -54,6 +55,7 @@ fun ListItem(
|
|||
trailingContent: ListItemContent? = null,
|
||||
style: ListItemStyle = ListItemStyle.Default,
|
||||
enabled: Boolean = true,
|
||||
alwaysClickable: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val colors = ListItemDefaults.colors(
|
||||
|
|
@ -74,6 +76,7 @@ fun ListItem(
|
|||
trailingContent = trailingContent,
|
||||
colors = colors,
|
||||
enabled = enabled,
|
||||
alwaysClickable = alwaysClickable,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -87,6 +90,7 @@ fun ListItem(
|
|||
* @param leadingContent The content to be displayed before 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 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.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -99,6 +103,7 @@ fun ListItem(
|
|||
leadingContent: ListItemContent? = null,
|
||||
trailingContent: ListItemContent? = null,
|
||||
enabled: Boolean = true,
|
||||
alwaysClickable: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
// 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,
|
||||
modifier = if (onClick != null) {
|
||||
Modifier
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.clickable(enabled = enabled || alwaysClickable, onClick = onClick)
|
||||
.then(modifier)
|
||||
} else {
|
||||
modifier
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -56,7 +56,27 @@ interface SessionVerificationService {
|
|||
/**
|
||||
* 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. */
|
||||
|
|
@ -82,20 +102,20 @@ sealed interface VerificationFlowState {
|
|||
data object Initial : VerificationFlowState
|
||||
|
||||
/** 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. */
|
||||
data object StartedSasVerification : VerificationFlowState
|
||||
data object DidStartSasVerification : VerificationFlowState
|
||||
|
||||
/** Verification data for the SAS verification received. */
|
||||
data class ReceivedVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState
|
||||
|
||||
/** Verification completed successfully. */
|
||||
data object Finished : VerificationFlowState
|
||||
data object DidFinish : VerificationFlowState
|
||||
|
||||
/** Verification was cancelled by either device. */
|
||||
data object Canceled : VerificationFlowState
|
||||
data object DidCancel : VerificationFlowState
|
||||
|
||||
/** Verification failed with an error. */
|
||||
data object Failed : VerificationFlowState
|
||||
data object DidFail : VerificationFlowState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.room.preview.RoomPreview
|
||||
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
|
||||
|
||||
object RoomPreviewMapper {
|
||||
fun map(roomPreview: RustRoomPreview): RoomPreview {
|
||||
return RoomPreview(
|
||||
roomId = RoomId(roomPreview.roomId),
|
||||
canonicalAlias = roomPreview.canonicalAlias?.let(::RoomAlias),
|
||||
name = roomPreview.name,
|
||||
topic = roomPreview.topic,
|
||||
avatarUrl = roomPreview.avatarUrl,
|
||||
numberOfJoinedMembers = roomPreview.numJoinedMembers.toLong(),
|
||||
roomType = roomPreview.roomType.toRoomType(),
|
||||
isHistoryWorldReadable = roomPreview.isHistoryWorldReadable,
|
||||
isJoined = roomPreview.isJoined,
|
||||
isInvited = roomPreview.isInvited,
|
||||
isPublic = roomPreview.isPublic,
|
||||
canKnock = roomPreview.canKnock
|
||||
)
|
||||
return roomPreview.use {
|
||||
val info = roomPreview.info()
|
||||
RoomPreview(
|
||||
roomId = RoomId(info.roomId),
|
||||
canonicalAlias = info.canonicalAlias?.let(::RoomAlias),
|
||||
name = info.name,
|
||||
topic = info.topic,
|
||||
avatarUrl = info.avatarUrl,
|
||||
numberOfJoinedMembers = info.numJoinedMembers.toLong(),
|
||||
roomType = info.roomType.toRoomType(),
|
||||
isHistoryWorldReadable = info.isHistoryWorldReadable,
|
||||
isJoined = info.membership == Membership.JOINED,
|
||||
isInvited = info.membership == Membership.INVITED,
|
||||
isPublic = info.joinRule == JoinRule.Public,
|
||||
canKnock = info.joinRule == JoinRule.Knock
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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.SessionVerificationServiceListener
|
||||
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.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
|
|
@ -41,6 +45,7 @@ import org.matrix.rustcomponents.sdk.use
|
|||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||
|
||||
class RustSessionVerificationService(
|
||||
private val client: Client,
|
||||
|
|
@ -100,6 +105,16 @@ class RustSessionVerificationService(
|
|||
.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 {
|
||||
initVerificationControllerIfNeeded()
|
||||
verificationController.requestVerification()
|
||||
|
|
@ -119,9 +134,24 @@ class RustSessionVerificationService(
|
|||
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) {
|
||||
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 {
|
||||
Timber.e(it, "Failed to verify session")
|
||||
didFail()
|
||||
|
|
@ -132,16 +162,16 @@ class RustSessionVerificationService(
|
|||
|
||||
// When verification attempt is accepted by the other device
|
||||
override fun didAcceptVerificationRequest() {
|
||||
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
|
||||
_verificationFlowState.value = VerificationFlowState.DidAcceptVerificationRequest
|
||||
}
|
||||
|
||||
override fun didCancel() {
|
||||
_verificationFlowState.value = VerificationFlowState.Canceled
|
||||
_verificationFlowState.value = VerificationFlowState.DidCancel
|
||||
}
|
||||
|
||||
override fun didFail() {
|
||||
Timber.e("Session verification failed with an unknown error")
|
||||
_verificationFlowState.value = VerificationFlowState.Failed
|
||||
_verificationFlowState.value = VerificationFlowState.DidFail
|
||||
}
|
||||
|
||||
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
|
||||
runCatching {
|
||||
withTimeout(30.seconds) {
|
||||
while (!verificationController.isVerified()) {
|
||||
while (encryptionService.verificationState() != VerificationState.VERIFIED) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
// 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()
|
||||
}
|
||||
.onFailure {
|
||||
|
|
@ -168,18 +198,18 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
|
||||
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(data.map())
|
||||
_verificationFlowState.value = VerificationFlowState.DidReceiveVerificationData(data.map())
|
||||
}
|
||||
|
||||
// When the actual SAS verification starts
|
||||
override fun didStartSasVerification() {
|
||||
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
|
||||
_verificationFlowState.value = VerificationFlowState.DidStartSasVerification
|
||||
}
|
||||
|
||||
// end-region
|
||||
|
||||
override suspend fun reset() {
|
||||
if (isReady.value) {
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||
// Cancel any pending verification attempt
|
||||
tryOrNull { verificationController.cancelVerification() }
|
||||
}
|
||||
|
|
@ -208,7 +238,7 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
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
|
||||
// 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")
|
||||
|
|
@ -227,7 +257,7 @@ class RustSessionVerificationService(
|
|||
Timber.d("Updating verification status: flow is pending or was finished some time ago")
|
||||
runCatching {
|
||||
initVerificationControllerIfNeeded()
|
||||
_sessionVerifiedStatus.value = if (verificationController.isVerified()) {
|
||||
_sessionVerifiedStatus.value = if (encryptionService.verificationState() == VerificationState.VERIFIED) {
|
||||
SessionVerifiedStatus.Verified
|
||||
} else {
|
||||
SessionVerifiedStatus.NotVerified
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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_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,
|
||||
isJoined: Boolean = true,
|
||||
isInvited: Boolean = true,
|
||||
isPublic: Boolean = true,
|
||||
canKnock: Boolean = true,
|
||||
): RoomPreview {
|
||||
return RoomPreview(
|
||||
membership: Membership? = Membership.JOINED,
|
||||
joinRule: JoinRule = JoinRule.Public,
|
||||
): RoomPreviewInfo {
|
||||
return RoomPreviewInfo(
|
||||
roomId = A_ROOM_ID.value,
|
||||
canonicalAlias = canonicalAlias,
|
||||
name = "name",
|
||||
|
|
@ -27,9 +27,7 @@ internal fun aRustRoomPreview(
|
|||
numJoinedMembers = 1u,
|
||||
roomType = null,
|
||||
isHistoryWorldReadable = true,
|
||||
isJoined = isJoined,
|
||||
isInvited = isInvited,
|
||||
isPublic = isPublic,
|
||||
canKnock = canKnock,
|
||||
membership = membership,
|
||||
joinRule = joinRule,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -10,19 +10,23 @@ package io.element.android.libraries.matrix.impl.room.preview
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
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.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_ID
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.JoinRule
|
||||
import org.matrix.rustcomponents.sdk.Membership
|
||||
|
||||
class RoomPreviewMapperTest {
|
||||
@Test
|
||||
fun `map should map values 1`() {
|
||||
assertThat(
|
||||
RoomPreviewMapper.map(
|
||||
aRustRoomPreview(
|
||||
isJoined = false,
|
||||
isInvited = false,
|
||||
FakeRustRoomPreview(
|
||||
info = aRustRoomPreviewInfo(
|
||||
membership = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
).isEqualTo(
|
||||
|
|
@ -38,7 +42,7 @@ class RoomPreviewMapperTest {
|
|||
isJoined = false,
|
||||
isInvited = false,
|
||||
isPublic = true,
|
||||
canKnock = true,
|
||||
canKnock = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -47,10 +51,12 @@ class RoomPreviewMapperTest {
|
|||
fun `map should map values 2`() {
|
||||
assertThat(
|
||||
RoomPreviewMapper.map(
|
||||
aRustRoomPreview(
|
||||
canonicalAlias = null,
|
||||
isPublic = false,
|
||||
canKnock = false,
|
||||
FakeRustRoomPreview(
|
||||
info = aRustRoomPreviewInfo(
|
||||
canonicalAlias = null,
|
||||
membership = Membership.JOINED,
|
||||
joinRule = JoinRule.Knock,
|
||||
)
|
||||
)
|
||||
)
|
||||
).isEqualTo(
|
||||
|
|
@ -64,9 +70,9 @@ class RoomPreviewMapperTest {
|
|||
roomType = RoomType.Room,
|
||||
isHistoryWorldReadable = true,
|
||||
isJoined = true,
|
||||
isInvited = true,
|
||||
isInvited = false,
|
||||
isPublic = false,
|
||||
canKnock = false,
|
||||
canKnock = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue