Browse Source

Incoming session verification request

Add more log to the state machines
Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution, the state machine may cancel the api call.
Let VerificationFlowState values match the SDK api for code clarity.
Rename sub interface for clarity.
Migrate tests to the new FakeVerificationService.
pull/3733/head
Benoit Marty 20 hours ago committed by Benoit Marty
parent
commit
b8b38208f4
  1. 27
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  2. 14
      features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
  3. 4
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
  4. 1
      features/verifysession/api/build.gradle.kts
  5. 33
      features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt
  6. 2
      features/verifysession/impl/build.gradle.kts
  7. 95
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
  8. 40
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt
  9. 12
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt
  10. 47
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt
  11. 189
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt
  12. 38
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt
  13. 158
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt
  14. 46
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt
  15. 235
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
  16. 16
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt
  17. 111
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt
  18. 2
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt
  19. 2
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt
  20. 81
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
  21. 22
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
  22. 35
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
  23. 73
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
  24. 204
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
  25. 2
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
  26. 33
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt
  27. 27
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt
  28. 94
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt
  29. 25
      features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt
  30. 292
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt
  31. 217
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt
  32. 227
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
  33. 30
      features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt
  34. 5
      libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt
  35. 15
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt
  36. 23
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt
  37. 34
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
  38. 55
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
  39. 23
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt
  40. 69
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt

27
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

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

14
features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt

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

4
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt

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

1
features/verifysession/api/build.gradle.kts

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

33
features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt

@ -0,0 +1,33 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
data class Params(
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onDone()
}
}

2
features/verifysession/impl/build.gradle.kts

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

95
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt

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

40
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt

@ -0,0 +1,40 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : IncomingVerificationEntryPoint.NodeBuilder {
override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node {
return parentNode.createNode<IncomingVerificationNode>(buildContext, plugins)
}
}
}
}

12
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt

@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
fun interface IncomingVerificationNavigator {
fun onFinish()
}

47
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt

@ -0,0 +1,47 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class IncomingVerificationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: IncomingVerificationPresenter.Factory,
) : Node(buildContext, plugins = plugins),
IncomingVerificationNavigator {
private val presenter = presenterFactory.create(
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
navigator = this,
)
override fun onFinish() {
plugins<IncomingVerificationEntryPoint.Callback>().forEach { it.onDone() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
IncomingVerificationView(
state = state,
modifier = modifier,
)
}
}

189
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt

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

38
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt

@ -0,0 +1,38 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@Immutable
data class IncomingVerificationState(
val step: Step,
val eventSink: (IncomingVerificationViewEvents) -> Unit,
) {
@Stable
sealed interface Step {
data class Initial(
val deviceDisplayName: String,
val deviceId: DeviceId,
val formattedSignInTime: String,
val isWaiting: Boolean,
) : Step
data class Verifying(
val data: SessionVerificationData,
val isWaiting: Boolean,
) : Step
data object Canceled : Step
data object Completed : Step
data object Failure : Step
}
}

158
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt

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

46
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt

@ -0,0 +1,46 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.matrix.api.core.DeviceId
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
override val values: Sequence<IncomingVerificationState>
get() = sequenceOf(
anIncomingVerificationState(),
anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
anIncomingVerificationState(step = Step.Completed),
anIncomingVerificationState(step = Step.Failure),
anIncomingVerificationState(step = Step.Canceled),
// Add other state here
)
}
internal fun aStepInitial(
isWaiting: Boolean = false,
) = Step.Initial(
deviceDisplayName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
formattedSignInTime = "12:34",
isWaiting = isWaiting,
)
internal fun anIncomingVerificationState(
step: Step = aStepInitial(),
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
) = IncomingVerificationState(
step = step,
eventSink = eventSink,
)

235
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt

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

16
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming
sealed interface IncomingVerificationViewEvents {
data object GoBack : IncomingVerificationViewEvents
data object StartVerification : IncomingVerificationViewEvents
data object IgnoreVerification : IncomingVerificationViewEvents
data object ConfirmVerification : IncomingVerificationViewEvents
data object DeclineVerification : IncomingVerificationViewEvents
}

111
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt

@ -0,0 +1,111 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.incoming.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SessionDetailsView(
deviceName: String,
deviceId: DeviceId,
signInFormattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = ElementTheme.colors.borderDisabled,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RoundedIconAtom(
modifier = Modifier,
size = RoundedIconAtomSize.Big,
resourceId = CompoundDrawables.ic_compound_devices
)
Text(
text = deviceName,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
text = signInFormattedTimestamp,
modifier = Modifier.weight(2f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = deviceId.value,
modifier = Modifier.weight(5f),
)
}
}
}
@Composable
private fun TextWithLabelMolecule(
label: String,
text: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = label,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun SessionDetailsViewPreview() = ElementPreview {
SessionDetailsView(
deviceName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}

2
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt

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

2
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt

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

81
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt

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

22
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt

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

35
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt

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

73
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt

@ -0,0 +1,73 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
step = Step.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
step = Step.Canceled
),
aVerifySelfSessionState(
step = Step.Ready
),
aVerifySelfSessionState(
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true)
),
aVerifySelfSessionState(
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
),
aVerifySelfSessionState(
step = Step.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
aVerifySelfSessionState(
step = Step.Loading
),
aVerifySelfSessionState(
step = Step.Skipped
),
// Add other state here
)
}
internal fun aVerifySelfSessionState(
step: Step = Step.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
step = step,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)

204
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt

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

2
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt → features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt

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

33
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt

@ -0,0 +1,33 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.ui
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
internal fun aEmojisSessionVerificationData(
emojiList: List<VerificationEmoji> = aVerificationEmojiList(),
): SessionVerificationData {
return SessionVerificationData.Emojis(emojiList)
}
internal fun aDecimalsSessionVerificationData(
decimals: List<Int> = listOf(123, 456, 789),
): SessionVerificationData {
return SessionVerificationData.Decimals(decimals)
}
private fun aVerificationEmojiList() = listOf(
VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
)

27
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt

@ -0,0 +1,27 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.ui
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@Composable
internal fun VerificationBottomMenu(
modifier: Modifier = Modifier,
buttons: @Composable ColumnScope.() -> Unit,
) {
ButtonColumnMolecule(
modifier = modifier.padding(bottom = 16.dp)
) {
buttons()
}
}

94
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt

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

25
features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.util
import com.freeletics.flowredux.dsl.InStateBuilderBlock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
import com.freeletics.flowredux.dsl.State as MachineState
internal fun <T : Any> T.andLogStateChange() = also {
Timber.w("Verification: state machine state moved to [${this::class.simpleName}]")
}
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <State : Any, reified Event : Any> InStateBuilderBlock<State, State, Event>.logReceivedEvents() {
on { event: Event, state: MachineState<State> ->
Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]")
state.noChange()
}
}

292
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt

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

217
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt

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

227
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt → features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt

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

30
features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt → features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt

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

5
libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt

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

15
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt

@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.core
import java.io.Serializable
@JvmInline
value class FlowId(val value: String) : Serializable {
override fun toString(): String = value
}

23
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.verification
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class SessionVerificationRequestDetails(
val senderId: UserId,
val flowId: FlowId,
val deviceId: DeviceId,
val displayName: String?,
val firstSeenTimestamp: Long,
) : Parcelable

34
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt

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

55
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt

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

23
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderId = UserId(senderId),
flowId = FlowId(flowId),
deviceId = DeviceId(deviceId),
displayName = displayName,
firstSeenTimestamp = firstSeenTimestamp.toLong(),
)

69
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt

@ -7,79 +7,84 @@
package io.element.android.libraries.matrix.test.verification package io.element.android.libraries.matrix.test.verification
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService( class FakeSessionVerificationService(
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown, initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
private val requestVerificationLambda: () -> Unit = { lambdaError() },
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
private val approveVerificationLambda: () -> Unit = { lambdaError() },
private val declineVerificationLambda: () -> Unit = { lambdaError() },
private val startVerificationLambda: () -> Unit = { lambdaError() },
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
) : SessionVerificationService { ) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus) private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _needsSessionVerification = MutableStateFlow(true) private var _needsSessionVerification = MutableStateFlow(true)
var shouldFail = false
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
override suspend fun requestVerification() { override suspend fun requestVerification() {
if (!shouldFail) { requestVerificationLambda()
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
} }
override suspend fun cancelVerification() { override suspend fun cancelVerification() {
_verificationFlowState.value = VerificationFlowState.Canceled cancelVerificationLambda()
} }
override suspend fun approveVerification() { override suspend fun approveVerification() {
if (!shouldFail) { approveVerificationLambda()
_verificationFlowState.value = VerificationFlowState.Finished
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
} }
override suspend fun declineVerification() { override suspend fun declineVerification() {
if (!shouldFail) { declineVerificationLambda()
_verificationFlowState.value = VerificationFlowState.Canceled
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
} }
fun triggerReceiveVerificationData(sessionVerificationData: SessionVerificationData) { override suspend fun startVerification() {
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(sessionVerificationData) startVerificationLambda()
} }
override suspend fun startVerification() { override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification resetLambda(cancelAnyPendingVerificationAttempt)
} }
fun givenVerifiedStatus(status: SessionVerifiedStatus) { var listener: SessionVerificationServiceListener? = null
_sessionVerifiedStatus.value = status private set
override fun setListener(listener: SessionVerificationServiceListener?) {
this.listener = listener
} }
suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) { override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
_sessionVerifiedStatus.emit(status) acknowledgeVerificationRequestLambda(details)
} }
fun givenVerificationFlowState(state: VerificationFlowState) { override suspend fun acceptVerificationRequest() = simulateLongTask {
_verificationFlowState.value = state acceptVerificationRequestLambda()
} }
fun givenNeedsSessionVerification(needsVerification: Boolean) { suspend fun emitVerificationFlowState(state: VerificationFlowState) {
_needsSessionVerification.value = needsVerification _verificationFlowState.emit(state)
}
suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) {
_sessionVerifiedStatus.emit(status)
} }
override suspend fun reset() { suspend fun emitNeedsSessionVerification(needsVerification: Boolean) {
_verificationFlowState.value = VerificationFlowState.Initial _needsSessionVerification.emit(needsVerification)
} }
} }

Loading…
Cancel
Save