From 9639d62bb3c232eda94583544c0212c86c3584cd Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 17 Mar 2023 10:07:19 +0100 Subject: [PATCH] Add Session Verification flow (#197) --- appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 12 + changelog.d/89.feature | 1 + features/logout/impl/build.gradle.kts | 2 + .../roomlist/api/RoomListEntryPoint.kt | 2 + .../features/roomlist/impl/RoomListEvents.kt | 2 + .../features/roomlist/impl/RoomListNode.kt | 5 + .../roomlist/impl/RoomListPresenter.kt | 25 ++ .../features/roomlist/impl/RoomListState.kt | 2 + .../roomlist/impl/RoomListStateProvider.kt | 6 +- .../features/roomlist/impl/RoomListView.kt | 147 +++++++-- .../roomlist/impl/RoomListPresenterTests.kt | 72 ++++- features/verifysession/api/build.gradle.kts | 29 ++ .../api/VerifySessionEntryPoint.kt | 21 ++ features/verifysession/impl/.gitignore | 1 + features/verifysession/impl/build.gradle.kts | 56 ++++ .../verifysession/impl/consumer-rules.pro | 0 .../impl/DefaultVerifySessionEntryPoint.kt | 32 ++ .../impl/VerifySelfSessionNode.kt | 45 +++ .../impl/VerifySelfSessionPresenter.kt | 109 +++++++ .../impl/VerifySelfSessionState.kt | 38 +++ .../impl/VerifySelfSessionStateMachine.kt | 171 +++++++++++ .../impl/VerifySelfSessionStateProvider.kt | 48 +++ .../impl/VerifySelfSessionView.kt | 289 ++++++++++++++++++ .../impl/VerifySelfSessionViewEvents.kt | 26 ++ .../res/drawable/ic_verification_devices.xml | 9 + .../res/drawable/ic_verification_emoji.xml | 9 + .../res/drawable/ic_verification_waiting.xml | 9 + .../res/drawable/ic_verification_warning.xml | 9 + .../impl/VerifySelfSessionPresenterTests.kt | 241 +++++++++++++++ libraries/core/build.gradle.kts | 3 + .../core/statemachine/StateMachine.kt | 192 ++++++++++++ .../core/statemachine/StateMachineTests.kt | 202 ++++++++++++ .../designsystem/ElementTextStyles.kt | 8 + .../components/CircularProgressIndicator.kt | 19 ++ .../libraries/matrix/api/MatrixClient.kt | 4 + .../SessionVerificationService.kt | 105 +++++++ .../api/verification/VerificationEmoji.kt | 22 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 25 ++ .../matrix/impl/di/SessionMatrixModule.kt | 35 +++ .../impl/sync/SlidingSyncObserverProxy.kt | 1 - .../RustSessionVerificationService.kt | 132 ++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 9 +- .../FakeSessionVerificationService.kt | 90 ++++++ .../src/main/res/values/strings_eax.xml | 25 ++ .../kotlin/extension/DependencyHandleScope.kt | 2 + .../android/samples/minimal/RoomListScreen.kt | 3 +- settings.gradle.kts | 2 + ...ationHeaderDark_0_null,NEXUS_5,1.0,en].png | 3 + ...tionHeaderLight_0_null,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...ationHeaderDark_0_null,NEXUS_5,1.0,en].png | 3 + ...tionHeaderLight_0_null,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...ewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...wLightPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + 76 files changed, 2347 insertions(+), 35 deletions(-) create mode 100644 changelog.d/89.feature create mode 100644 features/verifysession/api/build.gradle.kts create mode 100644 features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt create mode 100644 features/verifysession/impl/.gitignore create mode 100644 features/verifysession/impl/build.gradle.kts create mode 100644 features/verifysession/impl/consumer-rules.pro create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt create mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt create mode 100644 features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml create mode 100644 features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml create mode 100644 features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml create mode 100644 features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml create mode 100644 features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/statemachine/StateMachine.kt create mode 100644 libraries/core/src/test/kotlin/io/element/android/libraries/core/statemachine/StateMachineTests.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index d43e832fe9..082d876fbd 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) + implementation(projects.features.verifysession.api) implementation(projects.tests.uitests) implementation(libs.coil) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 938bb96116..4b2d653d79 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -38,6 +38,7 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint +import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -61,6 +62,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val preferencesEntryPoint: PreferencesEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint, private val appNavigationStateService: AppNavigationStateService, + private val verifySessionEntryPoint: VerifySessionEntryPoint, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.RoomList, @@ -120,6 +122,9 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize object CreateRoom : NavTarget + + @Parcelize + object VerifySession : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -137,6 +142,10 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onCreateRoomClicked() { backstack.push(NavTarget.CreateRoom) } + + override fun onSessionVerificationClicked() { + backstack.push(NavTarget.VerifySession) + } } roomListEntryPoint .nodeBuilder(this, buildContext) @@ -171,6 +180,9 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.CreateRoom -> { createRoomEntryPoint.createNode(this, buildContext) } + NavTarget.VerifySession -> { + verifySessionEntryPoint.createNode(this, buildContext) + } } } diff --git a/changelog.d/89.feature b/changelog.d/89.feature new file mode 100644 index 0000000000..eaee08de08 --- /dev/null +++ b/changelog.d/89.feature @@ -0,0 +1 @@ +Add self session verification flow. diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index 86c5ed7bac..6a56ac09d7 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(projects.libraries.elementresources) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(libs.accompanist.placeholder) api(projects.features.logout.api) ksp(libs.showkase.processor) diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt index a675924351..b356d154f1 100644 --- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -34,6 +34,8 @@ interface RoomListEntryPoint : FeatureEntryPoint { fun onRoomClicked(roomId: RoomId) fun onCreateRoomClicked() fun onSettingsClicked() + + fun onSessionVerificationClicked() } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index 40248b3723..47c34d6a5c 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -19,4 +19,6 @@ package io.element.android.features.roomlist.impl sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents + object DismissRequestVerificationPrompt : RoomListEvents + object ClearSuccessfulVerificationMessage : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index ac223c559d..5cf9c60aaf 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -48,6 +48,10 @@ class RoomListNode @AssistedInject constructor( plugins().forEach { it.onCreateRoomClicked() } } + private fun onSessionVerificationClicked() { + plugins().forEach { it.onSessionVerificationClicked() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -57,6 +61,7 @@ class RoomListNode @AssistedInject constructor( onRoomClicked = this::onRoomClicked, onOpenSettings = this::onOpenSettings, onCreateRoomClicked = this::onCreateRoomClicked, + onVerifyClicked = this::onSessionVerificationClicked, ) } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 73520e08fb..b93c8c00d7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,6 +36,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -49,6 +53,7 @@ private const val extendedRangeSize = 40 class RoomListPresenter @Inject constructor( private val client: MatrixClient, private val lastMessageFormatter: LastMessageFormatter, + private val sessionVerificationService: SessionVerificationService, ) : Presenter { @Composable @@ -71,20 +76,40 @@ class RoomListPresenter @Inject constructor( initialLoad(matrixUser) } + // Session verification status (unknown, not verified, verified) + val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() + var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) } + // We combine both values to only display the prompt if the session is not verified and it wasn't dismissed + val displayVerificationPrompt by remember { + derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } + } + + // Current verification flow status, if any (initial, requesting, accepted, etc.) + val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState() + // We only care about the 'Finished' state to display the 'verification success' message + val presentVerificationSuccessfulMessage = remember { + derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished } + } + fun handleEvents(event: RoomListEvents) { when (event) { is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) + RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true + RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset() } } LaunchedEffect(roomSummaries, filter) { filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) } + return RoomListState( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, + presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value, + displayVerificationPrompt = displayVerificationPrompt, eventSink = ::handleEvents ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 6831c729d4..122c5e5506 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -26,5 +26,7 @@ data class RoomListState( val matrixUser: MatrixUser?, val roomList: ImmutableList, val filter: String, + val presentVerificationSuccessfulMessage: Boolean, + val displayVerificationPrompt: Boolean, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 4fdbf585b9..68db631675 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -29,6 +29,8 @@ open class RoomListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomListState(), + aRoomListState().copy(displayVerificationPrompt = true), + aRoomListState().copy(presentVerificationSuccessfulMessage = true), ) } @@ -36,7 +38,9 @@ internal fun aRoomListState() = RoomListState( matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")), roomList = aRoomListRoomSummaryList(), filter = "filter", - eventSink = {} + eventSink = {}, + presentVerificationSuccessfulMessage = false, + displayVerificationPrompt = false, ) internal fun aRoomListRoomSummaryList(): ImmutableList { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 09d8f27449..2b8b1828a9 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -16,16 +16,31 @@ package io.element.android.features.roomlist.impl +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState 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 @@ -33,17 +48,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.ui.model.MatrixUser @@ -57,40 +78,27 @@ fun RoomListView( modifier: Modifier = Modifier, onRoomClicked: (RoomId) -> Unit = {}, onOpenSettings: () -> Unit = {}, + onVerifyClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {}, ) { - fun onFilterChanged(filter: String) { - state.eventSink(RoomListEvents.UpdateFilter(filter)) - } - - fun onVisibleRangedChanged(range: IntRange) { - state.eventSink(RoomListEvents.UpdateVisibleRange(range)) - } - RoomListContent( - roomSummaries = state.roomList, - matrixUser = state.matrixUser, - filter = state.filter, + state = state, modifier = modifier, onRoomClicked = onRoomClicked, - onFilterChanged = ::onFilterChanged, onOpenSettings = onOpenSettings, - onScrollOver = ::onVisibleRangedChanged, + onVerifyClicked = onVerifyClicked, onCreateRoomClicked = onCreateRoomClicked, ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun RoomListContent( - roomSummaries: ImmutableList, - matrixUser: MatrixUser?, - filter: String, + state: RoomListState, modifier: Modifier = Modifier, + onVerifyClicked: () -> Unit = {}, onRoomClicked: (RoomId) -> Unit = {}, - onFilterChanged: (String) -> Unit = {}, onOpenSettings: () -> Unit = {}, - onScrollOver: (IntRange) -> Unit = {}, onCreateRoomClicked: () -> Unit = {}, ) { fun onRoomClicked(room: RoomListRoomSummary) { @@ -117,19 +125,31 @@ fun RoomListContent( val nestedScrollConnection = remember { object : NestedScrollConnection { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - onScrollOver(visibleRange) + state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange)) return super.onPostFling(consumed, available) } } } + val snackbarHostState = remember { SnackbarHostState() } + val verificationCompleteMessage = stringResource(StringR.string.verification_conclusion_ok_self_notice_title) + LaunchedEffect(state.presentVerificationSuccessfulMessage) { + if (state.presentVerificationSuccessfulMessage) { + snackbarHostState.showSnackbar( + message = verificationCompleteMessage, + duration = SnackbarDuration.Short + ) + state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) + } + } + Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { RoomListTopBar( - matrixUser = matrixUser, - filter = filter, - onFilterChanged = onFilterChanged, + matrixUser = state.matrixUser, + filter = state.filter, + onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onOpenSettings = onOpenSettings, scrollBehavior = scrollBehavior, modifier = Modifier, @@ -146,8 +166,16 @@ fun RoomListContent( .nestedScroll(nestedScrollConnection), state = lazyListState, ) { + if (state.displayVerificationPrompt) { + item { + RequestVerificationHeader( + onVerifyClicked = onVerifyClicked, + onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } + ) + } + } items( - items = roomSummaries, + items = state.roomList, contentType = { room -> room.contentType() }, ) { room -> RoomSummaryRow(room = room, onClick = ::onRoomClicked) @@ -164,9 +192,80 @@ fun RoomListContent( Icon(resourceId = DrawableR.drawable.ic_edit_square, contentDescription = stringResource(id = StringR.string.a11y_create_message)) } }, + snackbarHost = { + SnackbarHost (snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } + }, ) } +@Composable +internal fun RequestVerificationHeader( + onVerifyClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Surface( + modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row { + Text( + stringResource(StringR.string.session_verification_banner_title), + modifier = Modifier.weight(1f), + style = ElementTextStyles.Bold.body, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Start, + ) + Icon( + modifier = Modifier.clickable(onClick = onDismissClicked), + imageVector = Icons.Default.Close, + contentDescription = stringResource(StringR.string.action_close) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text(stringResource(StringR.string.session_verification_banner_message), style = ElementTextStyles.Regular.bodyMD) + Spacer(modifier = Modifier.height(12.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp), + onClick = onVerifyClicked, + ) { + Text(stringResource(StringR.string.session_verification_start), style = ElementTextStyles.Button) + } + } + } + } +} + +@Preview +@Composable +internal fun PreviewRequestVerificationHeaderLight() { + ElementPreviewLight { + RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {}) + } +} + +@Preview +@Composable +internal fun PreviewRequestVerificationHeaderDark() { + ElementPreviewDark { + RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {}) + } +} + private fun RoomListRoomSummary.contentType() = isPlaceholder @Preview diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 02ef4fc89b..aae0dc57de 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -24,6 +24,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.dateformatter.api.LastMessageFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_MESSAGE @@ -35,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import kotlinx.coroutines.test.runTest import org.junit.Test @@ -44,7 +47,8 @@ class RoomListPresenterTests { fun `present - should start with no user and then load user with success`() = runTest { val presenter = RoomListPresenter( FakeMatrixClient(A_SESSION_ID), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -68,7 +72,8 @@ class RoomListPresenterTests { userDisplayName = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION), ), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -86,7 +91,8 @@ class RoomListPresenterTests { fun `present - should filter room with success`() = runTest { val presenter = RoomListPresenter( FakeMatrixClient(A_SESSION_ID), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -108,7 +114,8 @@ class RoomListPresenterTests { sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -135,7 +142,8 @@ class RoomListPresenterTests { sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -167,7 +175,8 @@ class RoomListPresenterTests { sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), - createDateFormatter() + createDateFormatter(), + FakeSessionVerificationService(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -202,6 +211,56 @@ class RoomListPresenterTests { } } + @Test + fun `present - handle DismissRequestVerificationPrompt`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = A_SESSION_ID, + roomSummaryDataSource = roomSummaryDataSource + ), + createDateFormatter(), + FakeSessionVerificationService().apply { + givenIsReady(true) + givenVerifiedStatus(SessionVerifiedStatus.NotVerified) + }, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val eventSink = awaitItem().eventSink + Truth.assertThat(awaitItem().displayVerificationPrompt).isTrue() + + eventSink(RoomListEvents.DismissRequestVerificationPrompt) + Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse() + } + } + + @Test + fun `present - presentVerificationSuccessfulMessage & ClearVerificationSuccesfulMessage`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = A_SESSION_ID, + roomSummaryDataSource = roomSummaryDataSource + ), + createDateFormatter(), + FakeSessionVerificationService().apply { + givenIsReady(true) + givenVerificationFlowState(VerificationFlowState.Finished) + }, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val displayMessageItem = awaitItem() + Truth.assertThat(displayMessageItem.presentVerificationSuccessfulMessage).isTrue() + displayMessageItem.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage) + Truth.assertThat(awaitItem().presentVerificationSuccessfulMessage).isFalse() + } + } + private fun createDateFormatter(): LastMessageFormatter { return FakeLastMessageFormatter().apply { givenFormat(A_FORMATTED_DATE) @@ -221,4 +280,3 @@ private val aRoomListRoomSummary = RoomListRoomSummary( avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME), isPlaceholder = false, ) - diff --git a/features/verifysession/api/build.gradle.kts b/features/verifysession/api/build.gradle.kts new file mode 100644 index 0000000000..3467d9790d --- /dev/null +++ b/features/verifysession/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.verifysession.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt new file mode 100644 index 0000000000..933ca1994f --- /dev/null +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface VerifySessionEntryPoint : SimpleFeatureEntryPoint diff --git a/features/verifysession/impl/.gitignore b/features/verifysession/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/verifysession/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts new file mode 100644 index 0000000000..ab42b9e9e5 --- /dev/null +++ b/features/verifysession/impl/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +android { + // TODO change the namespace (and your classes package) + namespace = "io.element.android.features.verifysession.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.elementresources) + implementation(projects.libraries.uiStrings) + implementation(libs.accompanist.flowlayout) + api(projects.features.verifysession.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + ksp(libs.showkase.processor) +} diff --git a/features/verifysession/impl/consumer-rules.pro b/features/verifysession/impl/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt new file mode 100644 index 0000000000..da8c22e756 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.verifysession.api.VerifySessionEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt new file mode 100644 index 0000000000..2eb9063e3d --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +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 VerifySelfSessionNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: VerifySelfSessionPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + VerifySelfSessionView( + state = state, + modifier = modifier, + goBack = { navigateUp() } + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt new file mode 100644 index 0000000000..5f43372e55 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import javax.inject.Inject +import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent +import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState + +class VerifySelfSessionPresenter @Inject constructor( + private val sessionVerificationService: SessionVerificationService, +) : Presenter { + + @Composable + override fun present(): VerifySelfSessionState { + LaunchedEffect(Unit) { + // Force reset, just in case the service was left in a broken state + sessionVerificationService.reset() + } + + val coroutineScope = rememberCoroutineScope() + val stateMachine = remember { VerifySelfSessionStateMachine(coroutineScope, sessionVerificationService) } + + // Create the new view state from the StateMachine state + val stateMachineCurrentState by stateMachine.state.collectAsState() + val verificationFlowState by remember { + derivedStateOf { stateMachineStateToViewState(stateMachineCurrentState) } + } + + fun handleEvents(event: VerifySelfSessionViewEvents) { + when (event) { + VerifySelfSessionViewEvents.RequestVerification -> stateMachine.process(StateMachineEvent.RequestVerification) + VerifySelfSessionViewEvents.StartSasVerification -> stateMachine.process(StateMachineEvent.StartSasVerification) + VerifySelfSessionViewEvents.Restart -> stateMachine.process(StateMachineEvent.Restart) + VerifySelfSessionViewEvents.ConfirmVerification -> stateMachine.process(StateMachineEvent.AcceptChallenge) + VerifySelfSessionViewEvents.DeclineVerification -> stateMachine.process(StateMachineEvent.DeclineChallenge) + VerifySelfSessionViewEvents.CancelAndClose -> { + if (stateMachineCurrentState !in sequenceOf( + StateMachineState.Initial, + StateMachineState.Completed, + StateMachineState.Canceled + ) + ) { + stateMachine.process(StateMachineEvent.Cancel) + } + } + } + } + + return VerifySelfSessionState( + verificationFlowStep = verificationFlowState, + eventSink = ::handleEvents, + ) + } + + private fun stateMachineStateToViewState(state: StateMachineState): VerifySelfSessionState.VerificationStep = + when (state) { + StateMachineState.Initial -> { + VerifySelfSessionState.VerificationStep.Initial + } + + StateMachineState.RequestingVerification, + StateMachineState.StartingSasVerification, + StateMachineState.SasVerificationStarted, + StateMachineState.VerificationRequestAccepted, + StateMachineState.Canceling -> { + VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse + } + + StateMachineState.Canceled -> { + VerifySelfSessionState.VerificationStep.Canceled + } + + is StateMachineState.Verifying -> { + val async = when (state) { + is StateMachineState.Verifying.Replying -> Async.Loading() + else -> Async.Uninitialized + } + VerifySelfSessionState.VerificationStep.Verifying(state.emojis, async) + } + + StateMachineState.Completed -> { + VerifySelfSessionState.VerificationStep.Completed + } + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt new file mode 100644 index 0000000000..6f24e238cc --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +@Immutable +data class VerifySelfSessionState( + val verificationFlowStep: VerificationStep, + val eventSink: (VerifySelfSessionViewEvents) -> Unit, +) { + + @Stable + sealed interface VerificationStep { + object Initial : VerificationStep + object Canceled : VerificationStep + object AwaitingOtherDeviceResponse : VerificationStep + data class Verifying(val emojiList: List, val state: Async) : VerificationStep + object Completed : VerificationStep + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt new file mode 100644 index 0000000000..466db2aa99 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("WildcardImport") +package io.element.android.features.verifysession.impl + +import io.element.android.libraries.core.statemachine.createStateMachine +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class VerifySelfSessionStateMachine( + coroutineScope: CoroutineScope, + private val sessionVerificationService: SessionVerificationService, +) { + + private val stateMachine = createStateMachine { + addInitialState(State.Initial) { + on(State.RequestingVerification) + on(State.StartingSasVerification) + } + addState { + onEnter { sessionVerificationService.requestVerification() } + + on(State.VerificationRequestAccepted) + on(State.Initial) + } + addState { + onEnter { sessionVerificationService.startVerification() } + } + addState { + on(State.StartingSasVerification) + } + addState { + on(State.RequestingVerification) + } + addState { + on { event, _ -> State.Verifying.ChallengeReceived(event.emojis) } + } + addState { + on { _, prevState -> State.Verifying.Replying(prevState.emojis, true) } + on { _, prevState -> State.Verifying.Replying(prevState.emojis, false) } + } + addState { + onEnter { state -> + if (state.accept) { + sessionVerificationService.approveVerification() + } else { + sessionVerificationService.declineVerification() + } + } + on(State.Completed) + } + addState { + onEnter { sessionVerificationService.cancelVerification() } + } + on(State.SasVerificationStarted) + on(State.Canceling) + on(State.Canceled) + on(State.Canceled) + } + + init { + // Observe the verification service state, translate it to state machine input events + sessionVerificationService.verificationFlowState.onEach { verificationAttemptState -> + when (verificationAttemptState) { + VerificationFlowState.AcceptedVerificationRequest -> { + stateMachine.process(Event.DidAcceptVerificationRequest) + } + VerificationFlowState.StartedSasVerification -> { + stateMachine.process(Event.DidStartSasVerification) + } + is VerificationFlowState.ReceivedVerificationData -> { + // For some reason we receive this state twice, we need to discard the 2nd one + if (stateMachine.currentState == State.SasVerificationStarted) { + stateMachine.process(Event.DidReceiveChallenge(verificationAttemptState.emoji)) + } + } + VerificationFlowState.Finished -> { + stateMachine.process(Event.DidAcceptChallenge) + } + VerificationFlowState.Canceled -> { + stateMachine.process(Event.DidCancel) + } + VerificationFlowState.Failed -> { + stateMachine.process(Event.DidFail) + } + else -> Unit + } + }.launchIn(coroutineScope) + } + + val state: StateFlow = stateMachine.stateFlow + + fun process(event: Event) = stateMachine.process(event) + + sealed interface State { + /** The initial state, before verification started. */ + object Initial : State + + /** Waiting for verification acceptance. */ + object RequestingVerification : State + + /** Verification request accepted. Waiting for start. */ + object VerificationRequestAccepted : State + + /** Waiting for SaS verification start. */ + object StartingSasVerification : State + + /** A SaS verification flow has been started. */ + object SasVerificationStarted : State + + sealed class Verifying(open val emojis: List) : State { + /** Verification accepted and emojis received. */ + data class ChallengeReceived(override val emojis: List) : Verifying(emojis) + + /** Replying to a verification challenge. */ + data class Replying(override val emojis: List, val accept: Boolean) : Verifying(emojis) + } + /** The verification is being canceled. */ + object Canceling : State + /** The verification has been canceled, remotely or locally. */ + object Canceled : State + /** Verification successful. */ + object Completed : State + } + + sealed interface Event { + /** Request verification. */ + object RequestVerification : Event + /** The current verification request has been accepted. */ + object DidAcceptVerificationRequest : Event + /** Start a SaS verification flow. */ + object StartSasVerification : Event + /** Started a SaS verification flow. */ + object DidStartSasVerification : Event + /** Has received emojis. */ + data class DidReceiveChallenge(val emojis: List) : Event + /** Emojis match. */ + object AcceptChallenge : Event + /** Emojis do not match. */ + object DeclineChallenge : Event + /** Remote accepted challenge. */ + object DidAcceptChallenge : Event + /** Request cancellation. */ + object Cancel : Event + /** Verification cancelled. */ + object DidCancel : Event + /** Request failed. */ + object DidFail : Event + /** Restart the verification flow. */ + object Restart : Event + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt new file mode 100644 index 0000000000..756857f1c1 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +open class VerifySelfSessionStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTemplateState(), + aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse), + aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Uninitialized)), + aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Loading())), + aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled), + // Add other state here + ) +} + +fun aTemplateState() = VerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial, + eventSink = {}, +) + +fun aVerificationEmojiList() = listOf( + VerificationEmoji("🍕", "Pizza"), + VerificationEmoji("🚀", "Rocket"), + VerificationEmoji("🚀", "Rocket"), + VerificationEmoji("🗺️", "Map"), + VerificationEmoji("🎳", "Bowling"), + VerificationEmoji("🎳", "Bowling"), + VerificationEmoji("📌", "Pin"), +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt new file mode 100644 index 0000000000..6470cb15e8 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.defaultMinSize +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.flowlayout.FlowRow +import com.google.accompanist.flowlayout.MainAxisAlignment +import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonCircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +fun VerifySelfSessionView( + state: VerifySelfSessionState, + modifier: Modifier = Modifier, + goBack: () -> Unit, +) { + fun goBackAndCancelIfNeeded() { + state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) + goBack() + } + if (state.verificationFlowStep is FlowStep.Completed) { + goBack() + } + BackHandler { + goBackAndCancelIfNeeded() + } + val verificationFlowStep = state.verificationFlowStep + val buttonsVisible by remember(verificationFlowStep) { + derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed } + } + Surface { + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + HeaderContent(verificationFlowStep = verificationFlowStep) + Content(flowState = verificationFlowStep) + } + if (buttonsVisible) { + BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded) + } + } + } +} + +@Composable +internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier = Modifier) { + val iconResourceId = when (verificationFlowStep) { + FlowStep.Initial -> R.drawable.ic_verification_devices + FlowStep.Canceled -> R.drawable.ic_verification_warning + FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting + is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji + } + val titleTextId = when (verificationFlowStep) { + FlowStep.Initial -> StringR.string.verification_title_initial + FlowStep.Canceled -> StringR.string.verification_title_canceled + FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_title_waiting + is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_title_verifying + } + val subtitleTextId = when (verificationFlowStep) { + FlowStep.Initial -> StringR.string.verification_subtitle_initial + FlowStep.Canceled -> StringR.string.verification_subtitle_canceled + FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_subtitle_waiting + is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_subtitle_verifying + } + Column(modifier) { + Spacer(Modifier.height(68.dp)) + Box( + modifier = Modifier + .size(width = 70.dp, height = 70.dp) + .align(Alignment.CenterHorizontally) + .background( + color = LocalColors.current.quinary, + shape = RoundedCornerShape(14.dp) + ) + ) { + Spacer(modifier = Modifier.height(68.dp)) + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(width = 48.dp, height = 48.dp), + tint = MaterialTheme.colorScheme.secondary, + resourceId = iconResourceId, + contentDescription = "", + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = titleTextId), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + style = ElementTextStyles.Bold.title2, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(id = subtitleTextId), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTextStyles.Regular.subheadline, + color = MaterialTheme.colorScheme.secondary, + ) + } +} + +@Composable +internal fun Content(flowState: FlowStep, modifier: Modifier = Modifier) { + Column (modifier){ + Spacer(Modifier.height(56.dp)) + when (flowState) { + FlowStep.Initial, FlowStep.Canceled, FlowStep.Completed -> Unit + FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting() + is FlowStep.Verifying -> ContentVerifying(flowState) + } + Spacer(Modifier.height(56.dp)) + } +} + +@Composable +internal fun ContentWaiting(modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } +} + +@Composable +internal fun ContentVerifying(verificationFlowStep: FlowStep.Verifying, modifier: Modifier = Modifier) { + FlowRow( + modifier = modifier.fillMaxWidth(), + mainAxisAlignment = MainAxisAlignment.Center, + mainAxisSpacing = 32.dp, + crossAxisSpacing = 40.dp + ) { + for (entry in verificationFlowStep.emojiList) { + Column( + modifier = Modifier.defaultMinSize(minWidth = 56.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(entry.code, fontSize = 34.sp) + Spacer(modifier = Modifier.height(16.dp)) + Text(entry.name, style = ElementTextStyles.Regular.body) + } + } + } +} + +@Composable +internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) { + val verificationViewState = screenState.verificationFlowStep + val eventSink = screenState.eventSink + + val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is Async.Loading + val positiveButtonTitle = when (verificationViewState) { + FlowStep.Initial -> StringR.string.verification_positive_button_initial + FlowStep.Canceled -> StringR.string.verification_positive_button_canceled + is FlowStep.Verifying -> { + if (isVerifying) { + StringR.string.verification_positive_button_verifying_ongoing + } else { + StringR.string.verification_positive_button_verifying_start + } + } + else -> null + } + val negativeButtonTitle = when (verificationViewState) { + FlowStep.Initial -> StringR.string.verification_negative_button_initial + FlowStep.Canceled -> StringR.string.verification_negative_button_canceled + is FlowStep.Verifying -> StringR.string.verification_negative_button_verifying + else -> null + } + val negativeButtonEnabled = !isVerifying + + val positiveButtonEvent = when (verificationViewState) { + FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification + is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null + FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart + else -> null + } + + val negativeButtonCallback: () -> Unit = when (verificationViewState) { + is FlowStep.Verifying -> { { eventSink(VerifySelfSessionViewEvents.DeclineVerification) } } + else -> goBack + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { positiveButtonEvent?.let { eventSink(it) } } + ) { + if (isVerifying) { + ButtonCircularProgressIndicator() + Spacer(Modifier.width(10.dp)) + } + positiveButtonTitle?.let { Text(stringResource(it)) } + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = negativeButtonCallback, + enabled = negativeButtonEnabled, + ) { + negativeButtonTitle?.let { Text(stringResource(it)) } + } + Spacer(Modifier.height(40.dp)) + } +} + +@Preview +@Composable +fun TemplateViewLightPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun TemplateViewDarkPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: VerifySelfSessionState) { + VerifySelfSessionView( + state = state, + goBack = {}, + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt new file mode 100644 index 0000000000..9c0fedada4 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +sealed interface VerifySelfSessionViewEvents { + object RequestVerification: VerifySelfSessionViewEvents + object StartSasVerification: VerifySelfSessionViewEvents + object Restart: VerifySelfSessionViewEvents + object ConfirmVerification: VerifySelfSessionViewEvents + object DeclineVerification: VerifySelfSessionViewEvents + object CancelAndClose: VerifySelfSessionViewEvents +} diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml new file mode 100644 index 0000000000..8ae6dd30fa --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml new file mode 100644 index 0000000000..82583a4011 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml new file mode 100644 index 0000000000..5b9f2e3cfc --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml new file mode 100644 index 0000000000..882ac62cd7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt new file mode 100644 index 0000000000..9a25a60323 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.verifysession.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.Event +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as VerificationStep +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class VerifySelfSessionPresenterTests { + + @Test + fun `present - Initial state is received`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial) + } + } + + @Test + fun `present - Handles requestVerification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.RequestVerification) + // Await for other device response: + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + // Finally, ChallengeReceived: + val verifyingState = awaitItem() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + } + } + + @Test + fun `present - Handles startSasVerification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.StartSasVerification) + // Await for other device response: + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + // ChallengeReceived: + val verifyingState = awaitItem() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + } + } + + @Test + fun `present - Cancelation on initial state does nothing`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.CancelAndClose) + expectNoEvents() + } + } + + @Test + fun `present - A fail in the flow cancels it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.RequestVerification) + + val verifyingState = awaitChallengeReceivedState() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + + service.shouldFail = true + eventSink(VerifySelfSessionViewEvents.ConfirmVerification) + + val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item)?.value } + assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + @Test + fun `present - Canceling the flow once it's verifying cancels it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.RequestVerification) + + val verifyingState = awaitChallengeReceivedState() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + + eventSink(VerifySelfSessionViewEvents.CancelAndClose) + + val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item)?.value } + assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + @Test + fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.RequestVerification) + + val verifyingState = awaitChallengeReceivedState() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + + service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(emptyList())) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - Restart after cancelation returns to requesting verification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + + eventSink(VerifySelfSessionViewEvents.RequestVerification) + assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized)) + + service.givenVerificationFlowState(VerificationFlowState.Canceled) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + + eventSink(VerifySelfSessionViewEvents.Restart) + // Went back to requesting verification + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved, the flow completes if there is no error`() = runTest { + val emojis = listOf( + VerificationEmoji("😄", "Smile") + ) + val service = FakeSessionVerificationService().apply { + givenEmojiList(emojis) + } + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + + eventSink(VerifySelfSessionViewEvents.RequestVerification) + assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Uninitialized)) + + eventSink(VerifySelfSessionViewEvents.ConfirmVerification) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Loading())) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed) + } + } + + @Test + fun `present - When verification is declined, the flow is canceled`() = runTest { + val service = FakeSessionVerificationService() + val presenter = VerifySelfSessionPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + + eventSink(VerifySelfSessionViewEvents.RequestVerification) + + assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized)) + eventSink(VerifySelfSessionViewEvents.DeclineVerification) + + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Loading())) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + private suspend fun ReceiveTurbine.awaitChallengeReceivedState(): VerifySelfSessionState { + // Skip 'waiting for response' state + skipItems(1) + // Received challenge + return awaitItem() + } +} diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index ef4a882cb3..1761d033d8 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -29,4 +29,7 @@ java { dependencies { implementation(libs.coroutines.core) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/statemachine/StateMachine.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/statemachine/StateMachine.kt new file mode 100644 index 0000000000..81451b7e74 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/statemachine/StateMachine.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.statemachine + +import io.element.android.libraries.core.bool.orFalse +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +fun createStateMachine( + config: StateMachineBuilder.() -> Unit +): StateMachine { + val builder = StateMachineBuilder() + config(builder) + return builder.build() +} + +class StateMachine( + val initialState: State, + private val stateConfigs: Map, StateConfig<*>>, + private val routes: List>, +) { + + private val _stateFlow = MutableStateFlow(initialState) + val stateFlow = _stateFlow.asStateFlow() + val currentState: State get() = stateFlow.value + + var transitionHandler: ((State, Event, State) -> Unit)? = null + + init { + @Suppress("UNCHECKED_CAST") + val initialStateConfig = stateConfigs[initialState::class.java] as StateConfig + initialStateConfig.onEnter?.invoke(initialState) + } + + @Suppress("UNCHECKED_CAST") + fun process(event: E) { + val route = findMatchingRoute(event) ?: error("No route found for state $currentState on event $event") + + val lastStateConfig: StateConfig? = stateConfigs[currentState::class.java] as? StateConfig + lastStateConfig?.onExit?.invoke(currentState) + + val nextState = route.toState(event, currentState) + transitionHandler?.invoke(currentState, event, nextState) + _stateFlow.value = nextState + + val currentStateConfig = stateConfigs[nextState::class.java] as? StateConfig + currentStateConfig?.onEnter?.invoke(nextState) + } + + private fun findMatchingRoute(event: E): StateMachineRoute? { + val routesForEvent = routes.filter { it.eventType.isInstance(event) } + + return (routesForEvent.firstOrNull { it.fromState?.isInstance(currentState).orFalse() } + ?: routesForEvent.firstOrNull { it.fromState == null }) as? StateMachineRoute + } + + fun restart() { + _stateFlow.value = initialState + } +} + +class StateMachineBuilder( + val routes: MutableList> = mutableListOf(), +) { + + lateinit var initialState: State + var stateConfigs = mutableMapOf, StateConfig>() + + inline fun addState(block: StateRegistrationBuilder.() -> Unit = {}) { + val config = StateConfig(S::class.java) + val registrationBuilder = StateRegistrationBuilder(config) + block(registrationBuilder) + + verifyRoutesAreUnique(S::class.java, routes, registrationBuilder.routes) + + if (stateConfigs.contains(S::class.java)) { + error("Duplicate registration for state ${S::class.java.name}") + } + stateConfigs[S::class.java] = config + routes.addAll(registrationBuilder.routes) + } + + inline fun addInitialState(state: S, config: StateRegistrationBuilder.() -> Unit = {}) { + initialState = state + addState(block = config) + } + + inline fun on(noinline configuration: (E, State) -> S) { + val builder = RouteBuilder(E::class.java, null) + builder.toState = configuration + val newRoute = builder.build() + verifyRoutesAreUnique(S::class.java, routes, listOf(newRoute)) + routes.add(newRoute) + } + + inline fun on(newState: State) { + val builder = RouteBuilder(E::class.java, null) + builder.toState = { _, _ -> newState } + val newRoute = builder.build() + verifyRoutesAreUnique(null, routes, listOf(newRoute)) + routes.add(newRoute) + } + + fun build(): StateMachine { + if (::initialState.isInitialized) { + return StateMachine(initialState, stateConfigs.toMap(), routes) + } else { + error("The state machine has no initial state") + } + } + + companion object { + fun verifyRoutesAreUnique( + state: Class<*>?, + oldRoutes: List>, + newRoutes: List>, + ) { + val oldEvents = oldRoutes.filter { it.fromState == state }.map { it.eventType } + val newEvents = newRoutes.filter { it.fromState == state }.map { it.eventType } + val intersection = oldEvents.intersect(newEvents) + if (intersection.isNotEmpty()) { + val duplicates = intersection.joinToString(", ") { it.name } + error("Duplicate registration in state ${state?.name} for events: $duplicates") + } + } + } +} + +class StateRegistrationBuilder( + val fromState: StateConfig, + val routes: MutableList> = mutableListOf(), +) { + + fun onEnter(enter: (State) -> Unit) { + fromState.onEnter = enter + } + + fun onExit(exit: (State) -> Unit) { + fromState.onExit = exit + } + + inline fun on(noinline configuration: (E, State) -> BaseState) { + val builder = RouteBuilder(E::class.java, fromState.state) + builder.toState = configuration + val newRoute = builder.build() + StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute)) + routes.add(newRoute) + } + + inline fun on(newState: BaseState) { + val builder = RouteBuilder(E::class.java, fromState.state) + builder.toState = { _, _ -> newState } + val newRoute = builder.build() + StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute)) + routes.add(newRoute) + } +} + +class RouteBuilder( + val eventType: Class, + val fromState: Class?, +) { + lateinit var toState: (Event, FromState) -> ToState + + fun build() = StateMachineRoute(eventType, fromState, toState) +} + +data class StateMachineRoute( + val eventType: Class, + val fromState: Class?, + val toState: (Event, FromState) -> ToState, +) + +data class StateConfig( + val state: Class, + var onEnter: ((State) -> Unit)? = null, + var onExit: ((State) -> Unit)? = null, +) diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/statemachine/StateMachineTests.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/statemachine/StateMachineTests.kt new file mode 100644 index 0000000000..2120724d79 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/statemachine/StateMachineTests.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.core.statemachine + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail +import org.junit.Test + +class StateMachineTests { + + sealed interface Events { + data class GoToSecond(val string: String) : Events + + object GoToThird : Events + + object GoToFourth : Events + + object Cancel : Events + } + + sealed interface States { + object First : States + + data class Second(val string: String) : States + + object Third : States + + object Fourth : States + object Canceled : States + } + + private var enteredSecondState = false + private var exitedFirstState = false + private var transitionHandlerParams: Triple? = null + private fun aStateMachine() = createStateMachine { + addInitialState(States.First) { + onExit { exitedFirstState = true } + on { first, _ -> + States.Second(first.string) + } + } + addState { + onEnter { enteredSecondState = true } + on(States.Third) + } + + addState() + + on { _, _ -> States.Fourth } + on(States.Canceled) + } + + @Test + fun `process - moves to next state given an event if the route exists`() = aStateMachine().run { + process(Events.GoToSecond("Hello")) + assertThat(currentState).isEqualTo(States.Second("Hello")) + process(Events.GoToThird) + assertThat(currentState).isEqualTo(States.Third) + process(Events.GoToFourth) + assertThat(currentState).isEqualTo(States.Fourth) + } + + @Test + fun `process - throws exception if there is no route for an event in a state`() = aStateMachine().run { + runCatching { + process(Events.GoToThird) + }.onSuccess { + fail("It should have thrown an error") + }.onFailure { + assertThat(it.message).startsWith("No route found for state") + } + Unit + } + + @Test + fun `process - calls onEnter and onExit callbacks when moving through states`() = aStateMachine().run { + process(Events.GoToSecond("Hello")) + assertThat(currentState).isEqualTo(States.Second("Hello")) + + assertThat(exitedFirstState).isTrue() + assertThat(enteredSecondState).isTrue() + } + + @Test + fun `process - if an Event route is registered inside a state and outside it, the internal registration takes precedence`() { + val customStateMachine = createStateMachine { + addInitialState(States.First) { + on(States.Canceled) + } + on(States.Fourth) + } + customStateMachine.process(Events.Cancel) + assertThat(customStateMachine.currentState).isEqualTo(States.Canceled) + } + + @Test + fun `transitionHandler - is called when moving from a state to another`() = aStateMachine().run { + transitionHandler = { from, event, to -> + transitionHandlerParams = Triple(from, event, to) + } + + process(Events.GoToSecond("Hello")) + + assertThat(transitionHandlerParams).isEqualTo( + Triple( + States.First, + Events.GoToSecond("Hello"), + States.Second("Hello"), + ) + ) + } + + @Test + fun `restart - sets the state machine to its initial state`() { + val customStateMachine = createStateMachine { + addInitialState(States.First) + on(States.Fourth) + } + customStateMachine.process(Events.GoToFourth) + assertThat(customStateMachine.currentState).isEqualTo(States.Fourth) + + customStateMachine.restart() + assertThat(customStateMachine.currentState).isEqualTo(customStateMachine.initialState) + } + + @Test + fun `init - the state machine must have registered a initial state`() { + runCatching { + createStateMachine { + addState() + on(States.Canceled) + } + }.onSuccess { + fail("It should have thrown an error") + }.onFailure { error -> + assertThat(error.message).isEqualTo("The state machine has no initial state") + } + Unit + } + + @Test + fun `init - the state machine having duplicate registrations for a state throws an error`() { + runCatching { + createStateMachine { + addInitialState(States.First) + addState() + } + }.onSuccess { + fail("It should have thrown an error") + }.onFailure { error -> + assertThat(error.message).startsWith("Duplicate registration for state ") + } + Unit + } + + @Test + fun `init - the state machine having duplicate registrations for an event inside a state throws an error`() { + runCatching { + createStateMachine { + addInitialState(States.First) { + on(States.Third) + on { _, _ -> States.Third } + } + } + }.onSuccess { + fail("It should have thrown an error") + }.onFailure { error -> + assertThat(error.message).startsWith("Duplicate registration in state") + } + Unit + } + + @Test + fun `init - the state machine having duplicate registrations for an event at the root level throws an error`() { + runCatching { + createStateMachine { + addInitialState(States.First) + on(States.Third) + on(States.Third) + } + }.onSuccess { + fail("It should have thrown an error") + }.onFailure { error -> + assertThat(error.message).startsWith("Duplicate registration in state") + } + Unit + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ElementTextStyles.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ElementTextStyles.kt index c23f9ff071..9af09f83db 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ElementTextStyles.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ElementTextStyles.kt @@ -196,6 +196,14 @@ object ElementTextStyles { textAlign = TextAlign.Start ) + val bodyMD = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + lineHeight = 20.sp, + textAlign = TextAlign.Start + ) + val footnote = TextStyle( fontSize = 13.sp, fontWeight = FontWeight.Normal, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt index 00a0f8c0de..c5fc69e264 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt @@ -16,11 +16,15 @@ package io.element.android.libraries.designsystem.theme.components +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp @Composable fun CircularProgressIndicator( @@ -49,3 +53,18 @@ fun CircularProgressIndicator( strokeWidth = strokeWidth, ) } + +@Composable +fun ButtonCircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onPrimary, + strokeWidth: Dp = 2.dp, +) { + CircularProgressIndicator( + modifier = modifier + .progressSemantics() + .size(18.dp), + color = color, + strokeWidth = strokeWidth, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index a0f6a472e8..e58a346fd6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.verification.SessionVerificationService interface MatrixClient { val sessionId: SessionId @@ -29,6 +30,7 @@ interface MatrixClient { fun startSync() fun stopSync() fun mediaResolver(): MediaResolver + fun sessionVerificationService(): SessionVerificationService suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result @@ -38,4 +40,6 @@ interface MatrixClient { width: Long, height: Long ): Result + + fun onSlidingSyncUpdate() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt new file mode 100644 index 0000000000..82c90efae9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.verification + +import kotlinx.coroutines.flow.StateFlow + +interface SessionVerificationService { + + /** + * State of the current verification flow ([VerificationFlowState.Initial] if not started). + */ + val verificationFlowState : StateFlow + + /** + * The internal service that checks verification can only run after the initial sync. + * This [StateFlow] will notify consumers when the service is ready to be used. + */ + val isReady: StateFlow + + /** + * Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified] + * or [SessionVerifiedStatus.Verified]. + */ + val sessionVerifiedStatus: StateFlow + + /** + * Request verification of the current session. + */ + fun requestVerification() + + /** + * Cancels the current verification attempt. + */ + fun cancelVerification() + + /** + * Approves the current verification. This must happen on both devices to successfully verify a session. + */ + fun approveVerification() + + /** + * Declines the verification attempt because the user could not verify or does not trust the other side of the verification. + */ + fun declineVerification() + + /** + * Starts the verification of the unverified session from another device. + */ + fun startVerification() + + /** + * Returns the verification service state to the initial step. + */ + fun reset() +} + +/** Verification status of the current session. */ +sealed interface SessionVerifiedStatus { + /** Unknown status, we couldn't read the actual value from the SDK. */ + object Unknown : SessionVerifiedStatus + + /** Not verified session status. */ + object NotVerified : SessionVerifiedStatus + + /** Verified session status. */ + object Verified : SessionVerifiedStatus +} + +/** States produced by the [SessionVerificationService]. */ +sealed interface VerificationFlowState { + /** Initial state. */ + object Initial : VerificationFlowState + + /** Session verification request was accepted by another device. */ + object AcceptedVerificationRequest : VerificationFlowState + + /** Short Authentication String (SAS) verification started between the 2 devices. */ + object StartedSasVerification : VerificationFlowState + + /** Verification data for the SAS verification (emojis) received. */ + data class ReceivedVerificationData(val emoji: List) : VerificationFlowState + + /** Verification completed successfully. */ + object Finished : VerificationFlowState + + /** Verification was cancelled by either device. */ + object Canceled : VerificationFlowState + + /** Verification failed with an error. */ + object Failed : VerificationFlowState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt new file mode 100644 index 0000000000..43b89b6587 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.verification + +data class VerificationEmoji( + val code: String, + val name: String, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 5cda9a9f8d..ecc0ec2e9c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -23,12 +23,17 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.media.RustMediaResolver import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy +import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate @@ -53,6 +58,9 @@ class RustMatrixClient constructor( override val sessionId: UserId = UserId(client.userId()) + private val verificationService = RustSessionVerificationService() + private var slidingSyncUpdateJob: Job? = null + private val clientDelegate = object : ClientDelegate { override fun didReceiveAuthError(isSoftLogout: Boolean) { Timber.v("didReceiveAuthError()") @@ -131,6 +139,9 @@ class RustMatrixClient constructor( client.setDelegate(clientDelegate) rustRoomSummaryDataSource.init() slidingSync.setObserver(slidingSyncObserverProxy) + slidingSyncUpdateJob = slidingSyncObserverProxy.updateSummaryFlow + .onEach { onSlidingSyncUpdate() } + .launchIn(coroutineScope) } private fun onRestartSync() { @@ -152,6 +163,8 @@ class RustMatrixClient constructor( override fun mediaResolver(): MediaResolver = mediaResolver + override fun sessionVerificationService(): SessionVerificationService = verificationService + override fun startSync() { if (client.isSoftLogout()) return if (isSyncing.compareAndSet(false, true)) { @@ -166,12 +179,14 @@ class RustMatrixClient constructor( } private fun close() { + slidingSyncUpdateJob?.cancel() stopSync() slidingSync.setObserver(null) rustRoomSummaryDataSource.close() client.setDelegate(null) visibleRoomsView.destroy() slidingSync.destroy() + verificationService.destroy() } override suspend fun logout() = withContext(dispatchers.io) { @@ -226,6 +241,16 @@ class RustMatrixClient constructor( } } + override fun onSlidingSyncUpdate() { + if (!verificationService.isReady.value) { + try { + verificationService.verificationController = client.getSessionVerificationController() + } catch (e: Throwable) { + Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.") + } + } + } + private fun File.deleteSessionDirectory(userID: String): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt new file mode 100644 index 0000000000..3079c75dc8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.verification.SessionVerificationService + +@Module +@ContributesTo(SessionScope::class) +object SessionMatrixModule { + @Provides + @SingleIn(SessionScope::class) + fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { + return matrixClient.sessionVerificationService() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncObserverProxy.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncObserverProxy.kt index 6c62171a65..e37c6e0854 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncObserverProxy.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncObserverProxy.kt @@ -36,7 +36,6 @@ class SlidingSyncObserverProxy( val updateSummaryFlow: SharedFlow = updateSummaryMutableFlow.asSharedFlow() override fun didReceiveSyncUpdate(summary: UpdateSummary) { - if (summary.rooms.isEmpty()) return coroutineScope.launch { updateSummaryMutableFlow.emit(summary) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt new file mode 100644 index 0000000000..04834da309 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.verification + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate +import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface +import org.matrix.rustcomponents.sdk.SessionVerificationEmoji +import javax.inject.Inject + +class RustSessionVerificationService @Inject constructor() : SessionVerificationService, SessionVerificationControllerDelegate { + + var verificationController: SessionVerificationControllerInterface? = null + set(value) { + field = value + _isReady.value = value != null + // If status was 'Unknown', move it to either 'Verified' or 'NotVerified' + if (value != null) { + updateVerificationStatus(value.isVerified()) + } + } + + private val _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) + override val verificationFlowState = _verificationFlowState.asStateFlow() + + private val _isReady = MutableStateFlow(false) + override val isReady = _isReady.asStateFlow() + + private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) + override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus.asStateFlow() + + override fun requestVerification() = tryOrFail { + verificationController?.setDelegate(this) + verificationController?.requestVerification() + } + + override fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() } + + override fun approveVerification() = tryOrFail { verificationController?.approveVerification() } + + override fun declineVerification() = tryOrFail { verificationController?.declineVerification() } + + override fun startVerification() = tryOrFail { + verificationController?.setDelegate(this) + verificationController?.startSasVerification() + } + + private fun tryOrFail(block: () -> Unit) { + runCatching { + block() + }.onFailure { didFail() } + } + + // region Delegate implementation + + // When verification attempt is accepted by the other device + override fun didAcceptVerificationRequest() { + _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest + } + + override fun didCancel() { + _verificationFlowState.value = VerificationFlowState.Canceled + } + + override fun didFail() { + _verificationFlowState.value = VerificationFlowState.Failed + } + + override fun didFinish() { + _verificationFlowState.value = VerificationFlowState.Finished + // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false + updateVerificationStatus(isVerified = true) + } + + override fun didReceiveVerificationData(data: List) { + val emojis = data.map { emoji -> + emoji.use { VerificationEmoji(it.symbol(), it.description()) } + } + _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojis) + } + + // When the actual SAS verification starts + override fun didStartSasVerification() { + _verificationFlowState.value = VerificationFlowState.StartedSasVerification + } + + // end-region + + override fun reset() { + if (isReady.value) { + // Cancel any pending verification attempt + tryOrNull { verificationController?.cancelVerification() } + } + _verificationFlowState.value = VerificationFlowState.Initial + } + + fun destroy() { + (verificationController as? SessionVerificationController)?.destroy() + verificationController = null + } + + private fun updateVerificationStatus(isVerified: Boolean) { + val newValue = when { + !isReady.value -> SessionVerifiedStatus.Unknown + !isVerified -> SessionVerifiedStatus.NotVerified + else -> SessionVerifiedStatus.Verified + } + _sessionVerifiedStatus.value = newValue + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index eb1010df87..dd597d394e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -22,16 +22,19 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.media.FakeMediaResolver import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import kotlinx.coroutines.delay class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), - override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() + override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), + private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService() ) : MatrixClient { private var logoutFailure: Throwable? = null @@ -72,4 +75,8 @@ class FakeMatrixClient( override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result { return Result.success(ByteArray(0)) } + + override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService + + override fun onSlidingSyncUpdate() {} } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt new file mode 100644 index 0000000000..4b885cee8c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.test.verification + +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeSessionVerificationService : SessionVerificationService { + private val _isReady = MutableStateFlow(false) + private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) + private var _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) + private var emojiList = emptyList() + var shouldFail = false + + override val verificationFlowState: StateFlow + get() = _verificationFlowState + + override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus + + override val isReady: StateFlow = _isReady + + override fun requestVerification() { + _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest + _verificationFlowState.value = VerificationFlowState.StartedSasVerification + _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList) + } + + override fun cancelVerification() { + _verificationFlowState.value = VerificationFlowState.Canceled + } + + override fun approveVerification() { + if (!shouldFail) { + _verificationFlowState.value = VerificationFlowState.Finished + } else { + _verificationFlowState.value = VerificationFlowState.Failed + } + } + + override fun declineVerification() { + if (!shouldFail) { + _verificationFlowState.value = VerificationFlowState.Canceled + } else { + _verificationFlowState.value = VerificationFlowState.Failed + } + } + + override fun startVerification() { + _verificationFlowState.value = VerificationFlowState.StartedSasVerification + _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList) + } + + fun givenVerifiedStatus(status: SessionVerifiedStatus) { + _sessionVerifiedStatus.value = status + } + + fun givenVerificationFlowState(state: VerificationFlowState) { + _verificationFlowState.value = state + } + + fun givenIsReady(value: Boolean) { + _isReady.value = value + } + + fun givenEmojiList(emojis: List) { + this.emojiList = emojis + } + + override fun reset() { + _verificationFlowState.value = VerificationFlowState.Initial + } +} diff --git a/libraries/ui-strings/src/main/res/values/strings_eax.xml b/libraries/ui-strings/src/main/res/values/strings_eax.xml index 5715cd53e6..4bb24b2dc2 100644 --- a/libraries/ui-strings/src/main/res/values/strings_eax.xml +++ b/libraries/ui-strings/src/main/res/values/strings_eax.xml @@ -19,4 +19,29 @@ Search for someone New room + Open an existing session + Waiting to accept request + Verification cancelled + Compare emojis + + Prove it\'s you in order to access your encrypted message history. + Accept the request to start the verification process in your other session to continue. + Something doesn\'t seem right. Either the request timed out or the request was denied. + Confirm that the emojis below match those shown on your other session. + + I am ready + Retry verification + They match + Waiting to match + + @string/action_cancel + @string/action_cancel + They don\'t match + + Access your message history + Looks like you\'re using a new device. Verify it\'s you to access your encrypted messages. + Continue + + Verification complete + diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 426be4fcdb..c084ae2db6 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -76,6 +76,7 @@ fun DependencyHandlerScope.allFeaturesApi() { implementation(project(":features:rageshake:api")) implementation(project(":features:preferences:api")) implementation(project(":features:createroom:api")) + implementation(project(":features:verifysession:api")) } fun DependencyHandlerScope.allFeaturesImpl() { @@ -87,4 +88,5 @@ fun DependencyHandlerScope.allFeaturesImpl() { implementation(project(":features:rageshake:impl")) implementation(project(":features:preferences:impl")) implementation(project(":features:createroom:impl")) + implementation(project(":features:verifysession:impl")) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index bc09cf78e7..714692d48f 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -40,7 +40,8 @@ class RoomListScreen( private val timeZone = TimeZone.currentSystemDefault() private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) - private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters)) + private val sessionVerificationService = matrixClient.sessionVerificationService() + private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters), sessionVerificationService) @Composable fun Content(modifier: Modifier = Modifier) { diff --git a/settings.gradle.kts b/settings.gradle.kts index e28b354cf3..e911b90075 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -85,3 +85,5 @@ include(":features:login:api") include(":features:login:impl") include(":features:createroom:api") include(":features:createroom:impl") +include(":features:verifysession:api") +include(":features:verifysession:impl") diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..21f1bae12a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:474c799a08ccba7c8b4d9ba3d120b103d0e8bb9654bb4da576d4eee579e5d517 +size 28222 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..902862a63b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c705a9c97a0bc5a2987cef4db6fd9b79de1cf1f5405035c6371615fb72fc5f36 +size 27715 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9a5b02d262 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f653439749b16d9f683b63984855e2dad576e5d567eeed792f28e9484b12b67f +size 60599 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4e36616412 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf +size 37781 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6ce2a97c89 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d88db44fb66340043d83e256b1eb7655e66af5e11daa55bd1336b4ff6e57883d +size 59472 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4a51d9cddc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b +size 37329 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..58a6311afb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d474dbae506ecab03cf32414bd4af4c16c934d49be170c6572f2492c6362350 +size 28356 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..47ecad911e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:745725187e65e850f04937783bbf99c3cc805dd3a9ce7580ac479c9ab2b0f9e1 +size 27829 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f7019f8b86 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e72c29b3a928eaa86ec7c97cb680cf2eaacebf6512390eb9dda47bdc2a9c7e +size 29249 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..aaf919abe1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76b4ac8395ac876c4ff979bd92279b9735fae581987326c6ab929e8578335c5a +size 26657 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fd7959efbe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca4957bb6995efbf398bf211da38cafb6b2c1693167f9c940d6a31343bfcf127 +size 61454 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e2a239d90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7590a2604bb93de8a416e7e45b6c410c27fd83481cfb08f3f41e820d22fc5582 +size 62046 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..22c9cd5705 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4861b0c969a8af115ad2638b54f5893e813633a61bebb8bccafa01cfda320ea9 +size 31937 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..32ff815fd8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ee6ee256e59932e2d6aa29c393ec30499da06c6759ed33e8298838618bbfb9f +size 28465 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34b728f940 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:586954a4fae4405f47a02140ec0ea9c89b2f6becc1b9b852d0a8fa28e10bf089 +size 25959 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fffb4c902d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083f2c5ba49a4579015c38ffa2e0c2dd4ed04f389faa4c90cf6afa6438e08ca7 +size 59174 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..25d0a3d145 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed5e914779a3dcfbed0af73cace4e9141259932a892ae57ee902aec4860bd879 +size 59628 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c08447d227 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c8534d5464f3ee2551df199d18af1589f6c1e0c66292756be1e1af9deee1b17 +size 31580 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1ec63cd893 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7513e7f34cb817fee0f74ca4a00204cd5d8b0928f80afdf487a9cc8e43a1fc8 +size 29289 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5646976fff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:280505afce4b14d28a13a2a4e16dc1bc0a5e9e350c9937d185fe19746ba2a415 +size 26658 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..373e0e2f7d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e9a966e2f3161eac1aedf146d033a47a9724b562dd07bffc3bb88b1e5ed00b2 +size 61502 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c428ab30b8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd3546ed310fb679f6226af97c5832f15a162906d21c15805cd19ecf5690a945 +size 62094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dd82508b8f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ea9c30f0f450962a591ad4e13ea19a2e8b13f863359c20d1edd3fec6675811d +size 31902 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8a9249347e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42ab717a579388fe13d1b5438c82348527f48f8387ee4336cf4d5483e1c1d4af +size 28512 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d9b9589972 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fe51076b770fabdda09687fed36a500bf05295f3cc599fc524cc87831a65a83 +size 25949 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..92a45e10fe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8136c7d1fe030e3575f7929c20ad22dd7d002143a57d934b6252d9eb0845f55 +size 59181 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..56cce44b58 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88cc80bb7272ef9fa012c8f394f8136f228fcf5903e65e7fb5ba03679b1ed0cd +size 59625 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3b96e4347c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession_null_DefaultGroup_TemplateViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae2530c339d94a6a615df644950e54d3b442e09e0721d036b26386bfa75525fe +size 31580