Implement user verification (#4294)
* Add support for starting verification of a user * Add support for replying to incoming user verification requests * Add reset recovery key button and previews to `ChooseSelfVerificationModeView` * Add 'Profile' item in room details screen * Update screenshots * Remove `showDeviceVerifiedScreen` parameter from `NavTarget.UseAnotherDevice` * Allow exiting the FTUE flow, which will close the app. The previous state will be restored when the app is reopened. * When outgoing verification fails, move to the `Canceled` state. Then, when resetting the state machine state also reset the verification service. --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
6f54de58f3
commit
b91933625c
145 changed files with 1662 additions and 830 deletions
|
|
@ -73,10 +73,13 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.verification.SessionVerificationRequestDetails
|
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
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
|
||||||
|
|
@ -84,6 +87,7 @@ import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
class LoggedInFlowNode @AssistedInject constructor(
|
class LoggedInFlowNode @AssistedInject constructor(
|
||||||
|
|
@ -127,8 +131,18 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
private val verificationListener = object : SessionVerificationServiceListener {
|
private val verificationListener = object : SessionVerificationServiceListener {
|
||||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
// Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
|
||||||
|
// This might be because this method is called back from Rust in a background thread.
|
||||||
|
MainScope().launch {
|
||||||
|
// Wait until the app is in foreground to display the incoming verification request
|
||||||
|
appNavigationStateService.appNavigationState.first { it.isInForeground }
|
||||||
|
|
||||||
|
// Wait for the UI to be ready
|
||||||
|
delay(500.milliseconds)
|
||||||
|
|
||||||
|
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,7 +232,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupAnvil
|
import extension.setupAnvil
|
||||||
|
import org.gradle.kotlin.dsl.test
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
@ -14,6 +15,12 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.ftue.impl"
|
namespace = "io.element.android.features.ftue.impl"
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
@ -30,6 +37,7 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.features.analytics.api)
|
implementation(projects.features.analytics.api)
|
||||||
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.features.securebackup.api)
|
implementation(projects.features.securebackup.api)
|
||||||
implementation(projects.features.verifysession.api)
|
implementation(projects.features.verifysession.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
|
@ -37,12 +45,16 @@ dependencies {
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
implementation(projects.libraries.permissions.noop)
|
implementation(projects.libraries.permissions.noop)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
|
implementation(projects.appconfig)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.analytics.noop)
|
testImplementation(projects.services.analytics.noop)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
|
|
||||||
import com.bumble.appyx.core.node.Node
|
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.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
|
@ -38,8 +37,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -59,7 +56,6 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Placeholder,
|
initialElement = NavTarget.Placeholder,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
backPressHandler = NoOpBackstackHandlerStrategy(),
|
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
|
|
@ -104,7 +100,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
NavTarget.Placeholder -> {
|
NavTarget.Placeholder -> {
|
||||||
createNode<PlaceholderNode>(buildContext)
|
createNode<PlaceholderNode>(buildContext)
|
||||||
}
|
}
|
||||||
NavTarget.SessionVerification -> {
|
is NavTarget.SessionVerification -> {
|
||||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
moveToNextStepIfNeeded()
|
moveToNextStepIfNeeded()
|
||||||
|
|
@ -175,11 +171,3 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
|
|
||||||
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
|
||||||
|
|
||||||
override fun onBackPressed() {
|
|
||||||
// No-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.di
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModePresenter
|
||||||
|
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeState
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@ContributesTo(SessionScope::class)
|
||||||
|
@Module
|
||||||
|
interface FtueModule {
|
||||||
|
@Binds
|
||||||
|
fun bindChooseSelfVerificationMethodPresenter(presenter: ChooseSelfVerificationModePresenter): Presenter<ChooseSelfVerificationModeState>
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.ftue.impl.sessionverification
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
|
@ -17,15 +18,21 @@ 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.newRoot
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
|
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 dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.appconfig.LearnMoreConfig
|
||||||
|
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
|
||||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||||
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||||
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.designsystem.utils.OpenUrlInTabView
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -37,7 +44,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||||
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
|
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root(showDeviceVerifiedScreen = false),
|
initialElement = NavTarget.Root,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -45,7 +52,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||||
) {
|
) {
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget
|
data object Root : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object UseAnotherDevice : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object EnterRecoveryKey : NavTarget
|
data object EnterRecoveryKey : NavTarget
|
||||||
|
|
@ -62,7 +72,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
// Move to the completed state view in the verification flow
|
// Move to the completed state view in the verification flow
|
||||||
backstack.newRoot(NavTarget.Root(showDeviceVerifiedScreen = true))
|
backstack.newRoot(NavTarget.UseAnotherDevice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,19 +80,43 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
is NavTarget.Root -> {
|
is NavTarget.Root -> {
|
||||||
verifySessionEntryPoint.nodeBuilder(this, buildContext)
|
val callback = object : ChooseSelfVerificationModeNode.Callback {
|
||||||
.params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen))
|
override fun onUseAnotherDevice() {
|
||||||
.callback(object : VerifySessionEntryPoint.Callback {
|
backstack.push(NavTarget.UseAnotherDevice)
|
||||||
override fun onEnterRecoveryKey() {
|
}
|
||||||
backstack.push(NavTarget.EnterRecoveryKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
override fun onUseRecoveryKey() {
|
||||||
|
backstack.push(NavTarget.EnterRecoveryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResetKey() {
|
||||||
|
backstack.push(NavTarget.ResetIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLearnMoreAboutEncryption() {
|
||||||
|
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createNode<ChooseSelfVerificationModeNode>(buildContext, plugins = listOf(callback))
|
||||||
|
}
|
||||||
|
is NavTarget.UseAnotherDevice -> {
|
||||||
|
verifySessionEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.params(VerifySessionEntryPoint.Params(
|
||||||
|
showDeviceVerifiedScreen = true,
|
||||||
|
verificationRequest = VerificationRequest.Outgoing.CurrentSession,
|
||||||
|
))
|
||||||
|
.callback(object : VerifySessionEntryPoint.Callback {
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
plugins<Callback>().forEach { it.onDone() }
|
plugins<Callback>().forEach { it.onDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetKey() {
|
override fun onBack() {
|
||||||
backstack.push(NavTarget.ResetIdentity)
|
backstack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLearnMoreAboutEncryption() {
|
||||||
|
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -106,8 +140,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val learnMoreUrl = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
BackstackView()
|
BackstackView()
|
||||||
|
|
||||||
|
OpenUrlInTabView(learnMoreUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
sealed interface ChooseSelfVerificationModeEvent {
|
||||||
|
data object SignOut : ChooseSelfVerificationModeEvent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
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.logout.api.direct.DirectLogoutView
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class)
|
||||||
|
class ChooseSelfVerificationModeNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: Presenter<ChooseSelfVerificationModeState>,
|
||||||
|
private val directLogoutView: DirectLogoutView,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onUseAnotherDevice()
|
||||||
|
fun onUseRecoveryKey()
|
||||||
|
fun onResetKey()
|
||||||
|
fun onLearnMoreAboutEncryption()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback = plugins<Callback>().first()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
|
||||||
|
ChooseSelfVerificationModeView(
|
||||||
|
state = state,
|
||||||
|
onUseAnotherDevice = callback::onUseAnotherDevice,
|
||||||
|
onUseRecoveryKey = callback::onUseRecoveryKey,
|
||||||
|
onResetKey = callback::onResetKey,
|
||||||
|
onLearnMore = callback::onLearnMoreAboutEncryption,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
directLogoutView.Render(state = state.directLogoutState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ChooseSelfVerificationModePresenter @Inject constructor(
|
||||||
|
private val encryptionService: EncryptionService,
|
||||||
|
private val directLogoutPresenter: Presenter<DirectLogoutState>,
|
||||||
|
) : Presenter<ChooseSelfVerificationModeState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): ChooseSelfVerificationModeState {
|
||||||
|
val isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||||
|
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||||
|
val canEnterRecoveryKey by remember { derivedStateOf { recoveryState == RecoveryState.INCOMPLETE } }
|
||||||
|
|
||||||
|
val directLogoutState = directLogoutPresenter.present()
|
||||||
|
|
||||||
|
fun eventHandler(event: ChooseSelfVerificationModeEvent) {
|
||||||
|
when (event) {
|
||||||
|
ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChooseSelfVerificationModeState(
|
||||||
|
isLastDevice = isLastDevice,
|
||||||
|
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||||
|
directLogoutState = directLogoutState,
|
||||||
|
eventSink = ::eventHandler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
|
|
||||||
|
data class ChooseSelfVerificationModeState(
|
||||||
|
val isLastDevice: Boolean,
|
||||||
|
val canEnterRecoveryKey: Boolean,
|
||||||
|
val directLogoutState: DirectLogoutState,
|
||||||
|
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
|
|
||||||
|
class ChooseSelfVerificationModeStateProvider :
|
||||||
|
PreviewParameterProvider<ChooseSelfVerificationModeState> {
|
||||||
|
override val values = sequenceOf(
|
||||||
|
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = true),
|
||||||
|
aChooseSelfVerificationModeState(isLastDevice = true, canEnterRecoveryKey = false),
|
||||||
|
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = true),
|
||||||
|
aChooseSelfVerificationModeState(isLastDevice = false, canEnterRecoveryKey = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aChooseSelfVerificationModeState(
|
||||||
|
isLastDevice: Boolean = false,
|
||||||
|
canEnterRecoveryKey: Boolean = true,
|
||||||
|
) = ChooseSelfVerificationModeState(
|
||||||
|
isLastDevice = isLastDevice,
|
||||||
|
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||||
|
directLogoutState = aDirectLogoutState(),
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.ftue.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
|
import io.element.android.libraries.designsystem.components.PageTitle
|
||||||
|
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.OutlinedButton
|
||||||
|
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.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChooseSelfVerificationModeView(
|
||||||
|
state: ChooseSelfVerificationModeState,
|
||||||
|
onUseAnotherDevice: () -> Unit,
|
||||||
|
onUseRecoveryKey: () -> Unit,
|
||||||
|
onResetKey: () -> Unit,
|
||||||
|
onLearnMore: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
BackHandler {
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderFooterPage(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
actions = {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(CommonStrings.action_signout),
|
||||||
|
onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
header = {
|
||||||
|
PageTitle(
|
||||||
|
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
|
||||||
|
title = stringResource(id = R.string.screen_identity_confirmation_title),
|
||||||
|
subtitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
ButtonColumnMolecule(
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
if (state.isLastDevice.not()) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = stringResource(R.string.screen_identity_use_another_device),
|
||||||
|
onClick = onUseAnotherDevice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.canEnterRecoveryKey) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||||
|
onClick = onUseRecoveryKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
|
||||||
|
onClick = onResetKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onLearnMore)
|
||||||
|
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||||
|
text = stringResource(CommonStrings.action_learn_more),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun ChooseSelfVerificationModeViewPreview(
|
||||||
|
@PreviewParameter(ChooseSelfVerificationModeStateProvider::class) state: ChooseSelfVerificationModeState
|
||||||
|
) = ElementPreview {
|
||||||
|
ChooseSelfVerificationModeView(
|
||||||
|
state = state,
|
||||||
|
onUseAnotherDevice = {},
|
||||||
|
onUseRecoveryKey = {},
|
||||||
|
onResetKey = {},
|
||||||
|
onLearnMore = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
|
||||||
|
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||||
|
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
|
||||||
|
<string name="screen_identity_confirmation_title">"Confirm your identity"</string>
|
||||||
|
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
|
||||||
|
<string name="screen_identity_confirmation_use_recovery_key">"Use recovery key"</string>
|
||||||
|
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
|
||||||
|
<string name="screen_identity_confirmed_title">"Device verified"</string>
|
||||||
|
<string name="screen_identity_use_another_device">"Use another device"</string>
|
||||||
|
<string name="screen_identity_waiting_on_other_device">"Waiting on other device…"</string>
|
||||||
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
|
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
|
||||||
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
|
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
|
||||||
|
<string name="screen_session_verification_enter_recovery_key">"Enter recovery key"</string>
|
||||||
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
||||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isn’t available yet."</string>
|
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isn’t available yet."</string>
|
||||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
|
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
|
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.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChooseSessionVerificationModePresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `initial state - is relayed from EncryptionService`() = runTest {
|
||||||
|
val encryptionService = FakeEncryptionService().apply {
|
||||||
|
// Is last device
|
||||||
|
emitIsLastDevice(true)
|
||||||
|
// Can enter recovery key
|
||||||
|
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||||
|
}
|
||||||
|
val presenter = createPresenter(encryptionService = encryptionService)
|
||||||
|
presenter.test {
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(isLastDevice).isTrue()
|
||||||
|
assertThat(canEnterRecoveryKey).isTrue()
|
||||||
|
assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sing out action triggers a direct logout`() = runTest {
|
||||||
|
val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {}
|
||||||
|
val logoutPresenter = Presenter<DirectLogoutState> {
|
||||||
|
aDirectLogoutState(eventSink = logoutEventRecorder)
|
||||||
|
}
|
||||||
|
val presenter = createPresenter(directLogoutPresenter = logoutPresenter)
|
||||||
|
presenter.test {
|
||||||
|
val initial = awaitItem()
|
||||||
|
initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
|
||||||
|
|
||||||
|
logoutEventRecorder.assertions().isCalledOnce().with(value(DirectLogoutEvents.Logout(ignoreSdkError = false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||||
|
directLogoutPresenter: Presenter<DirectLogoutState> = Presenter<DirectLogoutState> { aDirectLogoutState() }
|
||||||
|
) = ChooseSelfVerificationModePresenter(
|
||||||
|
encryptionService = encryptionService,
|
||||||
|
directLogoutPresenter = directLogoutPresenter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||||
|
|
||||||
|
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.ftue.impl.R
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ChooseSessionVerificationModeViewTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on learn more invokes the expected callback`() {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setChooseSelfVerificationModeView(
|
||||||
|
aChooseSelfVerificationModeState(),
|
||||||
|
onLearnMoreClick = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_learn_more)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on use another device calls the callback`() {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setChooseSelfVerificationModeView(
|
||||||
|
aChooseSelfVerificationModeState(isLastDevice = false),
|
||||||
|
onUseAnotherDevice = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_identity_use_another_device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on enter recovery key calls the callback`() {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setChooseSelfVerificationModeView(
|
||||||
|
aChooseSelfVerificationModeState(canEnterRecoveryKey = true),
|
||||||
|
onEnterRecoveryKey = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on cannot confirm calls the reset keys callback`() {
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setChooseSelfVerificationModeView(
|
||||||
|
aChooseSelfVerificationModeState(),
|
||||||
|
onResetKey = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseSelfVerificationModeView(
|
||||||
|
state: ChooseSelfVerificationModeState,
|
||||||
|
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onResetKey: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
ChooseSelfVerificationModeView(
|
||||||
|
state = state,
|
||||||
|
onLearnMore = onLearnMoreClick,
|
||||||
|
onUseAnotherDevice = onUseAnotherDevice,
|
||||||
|
onResetKey = onResetKey,
|
||||||
|
onUseRecoveryKey = onEnterRecoveryKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.logout.impl.ui
|
package io.element.android.features.logout.impl.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.features.logout.impl.R
|
import io.element.android.features.logout.impl.R
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ android {
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(projects.appconfig)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
@ -51,6 +52,7 @@ dependencies {
|
||||||
implementation(projects.features.messages.api)
|
implementation(projects.features.messages.api)
|
||||||
implementation(projects.features.roomcall.api)
|
implementation(projects.features.roomcall.api)
|
||||||
implementation(projects.features.knockrequests.api)
|
implementation(projects.features.knockrequests.api)
|
||||||
|
implementation(projects.features.verifysession.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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
|
||||||
|
|
@ -21,6 +22,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.Interaction
|
import im.vector.app.features.analytics.plan.Interaction
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.appconfig.LearnMoreConfig
|
||||||
import io.element.android.features.call.api.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
|
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
|
||||||
|
|
@ -35,11 +37,13 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot
|
||||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
||||||
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
|
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
|
||||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||||
|
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||||
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
|
||||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||||
import io.element.android.libraries.architecture.overlay.operation.show
|
import io.element.android.libraries.architecture.overlay.operation.show
|
||||||
|
import io.element.android.libraries.designsystem.utils.OpenUrlInTabView
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -47,6 +51,7 @@ 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.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -65,6 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||||
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
|
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
|
||||||
|
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||||
|
|
@ -118,6 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object SecurityAndPrivacy : NavTarget
|
data object SecurityAndPrivacy : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class VerifyUser(val userId: UserId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -168,6 +177,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
backstack.push(NavTarget.SecurityAndPrivacy)
|
backstack.push(NavTarget.SecurityAndPrivacy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun openDmUserProfile(userId: UserId) {
|
||||||
|
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onJoinCall() {
|
override fun onJoinCall() {
|
||||||
val inputs = CallType.RoomCall(
|
val inputs = CallType.RoomCall(
|
||||||
sessionId = room.sessionId,
|
sessionId = room.sessionId,
|
||||||
|
|
@ -224,6 +237,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
override fun onStartCall(dmRoomId: RoomId) {
|
override fun onStartCall(dmRoomId: RoomId) {
|
||||||
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
|
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVerifyUser(userId: UserId) {
|
||||||
|
backstack.push(NavTarget.VerifyUser(userId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
|
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
|
||||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||||
|
|
@ -301,11 +318,37 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
NavTarget.SecurityAndPrivacy -> {
|
NavTarget.SecurityAndPrivacy -> {
|
||||||
createNode<SecurityAndPrivacyFlowNode>(buildContext)
|
createNode<SecurityAndPrivacyFlowNode>(buildContext)
|
||||||
}
|
}
|
||||||
|
is NavTarget.VerifyUser -> {
|
||||||
|
val params = VerifySessionEntryPoint.Params(
|
||||||
|
showDeviceVerifiedScreen = true,
|
||||||
|
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
|
||||||
|
)
|
||||||
|
verifySessionEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.params(params)
|
||||||
|
.callback(object : VerifySessionEntryPoint.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBack() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLearnMoreAboutEncryption() {
|
||||||
|
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val learnMoreUrl = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
BackstackWithOverlayBox(modifier)
|
BackstackWithOverlayBox(modifier)
|
||||||
|
|
||||||
|
OpenUrlInTabView(learnMoreUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
|
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -50,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
fun openPinnedMessagesList()
|
fun openPinnedMessagesList()
|
||||||
fun openKnockRequestsList()
|
fun openKnockRequestsList()
|
||||||
fun openSecurityAndPrivacy()
|
fun openSecurityAndPrivacy()
|
||||||
|
fun openDmUserProfile(userId: UserId)
|
||||||
fun onJoinCall()
|
fun onJoinCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +128,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
callbacks.forEach { it.openSecurityAndPrivacy() }
|
callbacks.forEach { it.openSecurityAndPrivacy() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onProfileClick(userId: UserId) {
|
||||||
|
callbacks.forEach { it.openDmUserProfile(userId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -158,7 +164,8 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||||
onJoinCallClick = ::onJoinCall,
|
onJoinCallClick = ::onJoinCall,
|
||||||
onPinnedMessagesClick = ::openPinnedMessages,
|
onPinnedMessagesClick = ::openPinnedMessages,
|
||||||
onKnockRequestsClick = ::openKnockRequestsLists,
|
onKnockRequestsClick = ::openKnockRequestsLists,
|
||||||
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy
|
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
|
||||||
|
onProfileClick = ::onProfileClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
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.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||||
import io.element.android.libraries.matrix.api.room.getBestName
|
import io.element.android.libraries.matrix.api.room.getBestName
|
||||||
|
|
@ -101,6 +102,7 @@ fun RoomDetailsView(
|
||||||
onPinnedMessagesClick: () -> Unit,
|
onPinnedMessagesClick: () -> Unit,
|
||||||
onKnockRequestsClick: () -> Unit,
|
onKnockRequestsClick: () -> Unit,
|
||||||
onSecurityAndPrivacyClick: () -> Unit,
|
onSecurityAndPrivacyClick: () -> Unit,
|
||||||
|
onProfileClick: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -179,11 +181,16 @@ fun RoomDetailsView(
|
||||||
state.eventSink(RoomDetailsEvent.SetFavorite(it))
|
state.eventSink(RoomDetailsEvent.SetFavorite(it))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.canShowSecurityAndPrivacy) {
|
if (state.canShowSecurityAndPrivacy) {
|
||||||
SecurityAndPrivacyItem(
|
SecurityAndPrivacyItem(
|
||||||
onClick = onSecurityAndPrivacyClick
|
onClick = onSecurityAndPrivacyClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.roomMemberDetailsState?.let { dmMemberDetails ->
|
||||||
|
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.roomType is RoomDetailsType.Room) {
|
if (state.roomType is RoomDetailsType.Room) {
|
||||||
|
|
@ -546,6 +553,17 @@ private fun FavoriteItem(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileItem(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
|
||||||
|
headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) },
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MembersItem(
|
private fun MembersItem(
|
||||||
memberCount: Long,
|
memberCount: Long,
|
||||||
|
|
@ -655,5 +673,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||||
onPinnedMessagesClick = {},
|
onPinnedMessagesClick = {},
|
||||||
onKnockRequestsClick = {},
|
onKnockRequestsClick = {},
|
||||||
onSecurityAndPrivacyClick = {},
|
onSecurityAndPrivacyClick = {},
|
||||||
|
onProfileClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||||
onOpenDm = ::onStartDM,
|
onOpenDm = ::onStartDM,
|
||||||
onStartCall = ::onStartCall,
|
onStartCall = ::onStartCall,
|
||||||
openAvatarPreview = callback::openAvatarPreview,
|
openAvatarPreview = callback::openAvatarPreview,
|
||||||
|
onVerifyClick = callback::onVerifyUser,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
|
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
|
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
|
||||||
|
|
@ -294,6 +297,21 @@ class RoomDetailsViewTest {
|
||||||
rule.clickOn(R.string.screen_room_details_requests_to_join_title)
|
rule.clickOn(R.string.screen_room_details_requests_to_join_title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `click on profile invokes the expected callback`() {
|
||||||
|
ensureCalledOnceWithParam(A_USER_ID) { callback ->
|
||||||
|
rule.setRoomDetailView(
|
||||||
|
state = aRoomDetailsState(
|
||||||
|
eventSink = EventsRecorder(expectEvents = false),
|
||||||
|
roomMemberDetailsState = aUserProfileState(userId = A_USER_ID),
|
||||||
|
),
|
||||||
|
onProfileClick = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_room_details_profile_row_title)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
|
||||||
|
|
@ -314,6 +332,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||||
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
|
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
|
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
RoomDetailsView(
|
RoomDetailsView(
|
||||||
|
|
@ -332,6 +351,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||||
onPinnedMessagesClick = onPinnedMessagesClick,
|
onPinnedMessagesClick = onPinnedMessagesClick,
|
||||||
onKnockRequestsClick = onKnockRequestsClick,
|
onKnockRequestsClick = onKnockRequestsClick,
|
||||||
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
|
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
|
||||||
|
onProfileClick = onProfileClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.mediaviewer.api)
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
implementation(projects.features.call.api)
|
implementation(projects.features.call.api)
|
||||||
|
implementation(projects.features.verifysession.api)
|
||||||
api(projects.features.userprofile.api)
|
api(projects.features.userprofile.api)
|
||||||
api(projects.features.userprofile.shared)
|
api(projects.features.userprofile.shared)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||||
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
||||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||||
|
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||||
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
|
||||||
|
|
@ -32,7 +33,9 @@ import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -43,6 +46,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||||
private val sessionIdHolder: CurrentSessionIdHolder,
|
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||||
|
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root,
|
initialElement = NavTarget.Root,
|
||||||
|
|
@ -57,6 +61,9 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
|
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class VerifyUser(val userId: UserId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -74,6 +81,10 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
override fun onStartCall(dmRoomId: RoomId) {
|
override fun onStartCall(dmRoomId: RoomId) {
|
||||||
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
|
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVerifyUser(userId: UserId) {
|
||||||
|
backstack.push(NavTarget.VerifyUser(userId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
||||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||||
|
|
@ -96,6 +107,15 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
is NavTarget.VerifyUser -> {
|
||||||
|
val params = VerifySessionEntryPoint.Params(
|
||||||
|
showDeviceVerifiedScreen = false,
|
||||||
|
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
|
||||||
|
)
|
||||||
|
verifySessionEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.params(params)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class UserProfileNode @AssistedInject constructor(
|
||||||
onOpenDm = ::onStartDM,
|
onOpenDm = ::onStartDM,
|
||||||
onStartCall = callback::onStartCall,
|
onStartCall = callback::onStartCall,
|
||||||
openAvatarPreview = callback::openAvatarPreview,
|
openAvatarPreview = callback::openAvatarPreview,
|
||||||
|
onVerifyClick = callback::onVerifyUser,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ class UserProfilePresenter @AssistedInject constructor(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isCurrentUser = remember { client.isMe(userId) }
|
val isCurrentUser = remember { client.isMe(userId) }
|
||||||
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
||||||
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
|
||||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
|
|
@ -86,9 +85,8 @@ class UserProfilePresenter @AssistedInject constructor(
|
||||||
.onEach { isBlocked.value = AsyncData.Success(it) }
|
.onEach { isBlocked.value = AsyncData.Success(it) }
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
|
||||||
userProfile = client.getProfile(userId).getOrNull()
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
suspend {
|
suspend {
|
||||||
client.encryptionService().isUserVerified(userId).getOrThrow()
|
client.encryptionService().isUserVerified(userId).getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class UserProfileNodeHelper(
|
||||||
fun openAvatarPreview(username: String, avatarUrl: String)
|
fun openAvatarPreview(username: String, avatarUrl: String)
|
||||||
fun onStartDM(roomId: RoomId)
|
fun onStartDM(roomId: RoomId)
|
||||||
fun onStartCall(dmRoomId: RoomId)
|
fun onStartCall(dmRoomId: RoomId)
|
||||||
|
fun onVerifyUser(userId: UserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShareUser(
|
fun onShareUser(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
@ -50,6 +51,7 @@ fun UserProfileView(
|
||||||
onStartCall: (RoomId) -> Unit,
|
onStartCall: (RoomId) -> Unit,
|
||||||
goBack: () -> Unit,
|
goBack: () -> Unit,
|
||||||
openAvatarPreview: (username: String, url: String) -> Unit,
|
openAvatarPreview: (username: String, url: String) -> Unit,
|
||||||
|
onVerifyClick: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -82,7 +84,7 @@ fun UserProfileView(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(26.dp))
|
Spacer(modifier = Modifier.height(26.dp))
|
||||||
if (!state.isCurrentUser) {
|
if (!state.isCurrentUser) {
|
||||||
VerifyUserSection(state)
|
VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) })
|
||||||
BlockUserSection(state)
|
BlockUserSection(state)
|
||||||
BlockUserDialogs(state)
|
BlockUserDialogs(state)
|
||||||
}
|
}
|
||||||
|
|
@ -116,14 +118,15 @@ fun UserProfileView(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerifyUserSection(state: UserProfileState) {
|
private fun VerifyUserSection(
|
||||||
|
state: UserProfileState,
|
||||||
|
onVerifyClick: () -> Unit,
|
||||||
|
) {
|
||||||
if (state.isVerified.dataOrNull() == false) {
|
if (state.isVerified.dataOrNull() == false) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) },
|
headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
|
||||||
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
|
|
||||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
|
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
|
||||||
enabled = false,
|
onClick = onVerifyClick,
|
||||||
onClick = { },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +142,7 @@ internal fun UserProfileViewPreview(
|
||||||
goBack = {},
|
goBack = {},
|
||||||
onOpenDm = {},
|
onOpenDm = {},
|
||||||
onStartCall = {},
|
onStartCall = {},
|
||||||
openAvatarPreview = { _, _ -> }
|
openAvatarPreview = { _, _ -> },
|
||||||
|
onVerifyClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ import io.element.android.features.userprofile.shared.UserProfileView
|
||||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -193,6 +195,17 @@ class UserProfileViewTest {
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
|
eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on verify user clicked - the right callback is called`() = runTest {
|
||||||
|
ensureCalledOnceWithParam(A_USER_ID) { callback ->
|
||||||
|
rule.setUserProfileView(
|
||||||
|
state = aUserProfileState(userId = A_USER_ID, isVerified = AsyncData.Success(false)),
|
||||||
|
onVerifyClick = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.common_verify_user)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserProfileView(
|
||||||
|
|
@ -202,6 +215,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
|
||||||
onShareUser: () -> Unit = EnsureNeverCalled(),
|
onShareUser: () -> Unit = EnsureNeverCalled(),
|
||||||
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
onVerifyClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
goBack: () -> Unit = EnsureNeverCalled(),
|
goBack: () -> Unit = EnsureNeverCalled(),
|
||||||
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -213,6 +227,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
|
||||||
onStartCall = onStartCall,
|
onStartCall = onStartCall,
|
||||||
goBack = goBack,
|
goBack = goBack,
|
||||||
openAvatarPreview = openAvatarPreview,
|
openAvatarPreview = openAvatarPreview,
|
||||||
|
onVerifyClick = onVerifyClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
|
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
|
||||||
data class Params(
|
data class Params(
|
||||||
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
val verificationRequest: VerificationRequest.Incoming,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,13 @@ import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
interface VerifySessionEntryPoint : FeatureEntryPoint {
|
interface VerifySessionEntryPoint : FeatureEntryPoint {
|
||||||
data class Params(val showDeviceVerifiedScreen: Boolean) : NodeInputs
|
data class Params(
|
||||||
|
val showDeviceVerifiedScreen: Boolean,
|
||||||
|
val verificationRequest: VerificationRequest.Outgoing,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
||||||
|
|
@ -25,8 +29,8 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onEnterRecoveryKey()
|
fun onLearnMoreAboutEncryption()
|
||||||
fun onResetKey()
|
fun onBack()
|
||||||
fun onDone()
|
fun onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class IncomingVerificationNode @AssistedInject constructor(
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
IncomingVerificationNavigator {
|
IncomingVerificationNavigator {
|
||||||
private val presenter = presenterFactory.create(
|
private val presenter = presenterFactory.create(
|
||||||
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
|
verificationRequest = inputs<IncomingVerificationEntryPoint.Params>().verificationRequest,
|
||||||
navigator = this,
|
navigator = this,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@
|
||||||
package io.element.android.features.verifysession.impl.incoming
|
package io.element.android.features.verifysession.impl.incoming
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
|
@ -22,20 +24,25 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
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.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
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 timber.log.Timber
|
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.Event as StateMachineEvent
|
||||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
|
||||||
|
|
||||||
class IncomingVerificationPresenter @AssistedInject constructor(
|
class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
@Assisted private val verificationRequest: VerificationRequest.Incoming,
|
||||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||||
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
private val stateMachine: IncomingVerificationStateMachine,
|
private val stateMachine: IncomingVerificationStateMachine,
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
|
|
@ -43,49 +50,63 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
verificationRequest: VerificationRequest.Incoming,
|
||||||
navigator: IncomingVerificationNavigator,
|
navigator: IncomingVerificationNavigator,
|
||||||
): IncomingVerificationPresenter
|
): IncomingVerificationPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): IncomingVerificationState {
|
override fun present(): IncomingVerificationState {
|
||||||
LaunchedEffect(Unit) {
|
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||||
// 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 stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
// Force reset, just in case the service was left in a broken state
|
||||||
|
sessionVerificationService.reset(
|
||||||
|
cancelAnyPendingVerificationAttempt = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start this after observing state machine
|
||||||
|
observeVerificationService()
|
||||||
|
|
||||||
|
// Acknowledge the request right now
|
||||||
|
sessionVerificationService.acknowledgeVerificationRequest(verificationRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
sessionCoroutineScope.launch {
|
||||||
|
val currentState = stateAndDispatch.state.value
|
||||||
|
sessionVerificationService.reset(
|
||||||
|
cancelAnyPendingVerificationAttempt = currentState?.isPending() == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val formattedSignInTime = remember {
|
val formattedSignInTime = remember {
|
||||||
dateFormatter.format(
|
dateFormatter.format(
|
||||||
timestamp = sessionVerificationRequestDetails.firstSeenTimestamp,
|
timestamp = verificationRequest.details.firstSeenTimestamp,
|
||||||
mode = DateFormatterMode.TimeOrDate,
|
mode = DateFormatterMode.TimeOrDate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val step by remember {
|
val step by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
stateAndDispatch.state.value.toVerificationStep(
|
stateAndDispatch.state.value.toVerificationStep(
|
||||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
sessionVerificationRequestDetails = verificationRequest.details,
|
||||||
formattedSignInTime = formattedSignInTime,
|
formattedSignInTime = formattedSignInTime,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(stateAndDispatch.state.value) {
|
LaunchedEffect(stateAndDispatch.state.value) {
|
||||||
if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) {
|
if ((stateAndDispatch.state.value as? StateMachineState.Initial)?.isCancelled == true) {
|
||||||
// The verification was canceled before it was started, maybe because another session accepted it
|
// The verification was canceled before it was started, maybe because another session accepted it
|
||||||
navigator.onFinish()
|
navigator.onFinish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start this after observing state machine
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
observeVerificationService()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleEvents(event: IncomingVerificationViewEvents) {
|
fun handleEvents(event: IncomingVerificationViewEvents) {
|
||||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -119,6 +140,7 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
return IncomingVerificationState(
|
return IncomingVerificationState(
|
||||||
step = step,
|
step = step,
|
||||||
|
request = verificationRequest,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -129,36 +151,36 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
): Step =
|
): Step =
|
||||||
when (val machineState = this) {
|
when (val machineState = this) {
|
||||||
is StateMachineState.Initial,
|
is StateMachineState.Initial,
|
||||||
IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
|
StateMachineState.AcceptingIncomingVerification,
|
||||||
IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
StateMachineState.RejectingIncomingVerification,
|
||||||
null -> {
|
null -> {
|
||||||
Step.Initial(
|
Step.Initial(
|
||||||
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
|
deviceDisplayName = sessionVerificationRequestDetails.senderProfile.displayName ?: sessionVerificationRequestDetails.deviceId.value,
|
||||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||||
formattedSignInTime = formattedSignInTime,
|
formattedSignInTime = formattedSignInTime,
|
||||||
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
|
isWaiting = machineState == StateMachineState.AcceptingIncomingVerification ||
|
||||||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
machineState == StateMachineState.RejectingIncomingVerification,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is IncomingVerificationStateMachine.State.ChallengeReceived ->
|
is StateMachineState.ChallengeReceived ->
|
||||||
Step.Verifying(
|
Step.Verifying(
|
||||||
data = machineState.data,
|
data = machineState.data,
|
||||||
isWaiting = false,
|
isWaiting = false,
|
||||||
)
|
)
|
||||||
IncomingVerificationStateMachine.State.Completed -> Step.Completed
|
StateMachineState.Completed -> Step.Completed
|
||||||
IncomingVerificationStateMachine.State.Canceling,
|
StateMachineState.Canceling,
|
||||||
IncomingVerificationStateMachine.State.Failure -> Step.Failure
|
StateMachineState.Failure -> Step.Failure
|
||||||
is IncomingVerificationStateMachine.State.AcceptingChallenge ->
|
is StateMachineState.AcceptingChallenge ->
|
||||||
Step.Verifying(
|
Step.Verifying(
|
||||||
data = machineState.data,
|
data = machineState.data,
|
||||||
isWaiting = true,
|
isWaiting = true,
|
||||||
)
|
)
|
||||||
is IncomingVerificationStateMachine.State.RejectingChallenge ->
|
is StateMachineState.RejectingChallenge ->
|
||||||
Step.Verifying(
|
Step.Verifying(
|
||||||
data = machineState.data,
|
data = machineState.data,
|
||||||
isWaiting = true,
|
isWaiting = true,
|
||||||
)
|
)
|
||||||
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
|
StateMachineState.Canceled -> Step.Canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.observeVerificationService() {
|
private fun CoroutineScope.observeVerificationService() {
|
||||||
|
|
@ -170,10 +192,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
VerificationFlowState.DidAcceptVerificationRequest,
|
VerificationFlowState.DidAcceptVerificationRequest,
|
||||||
VerificationFlowState.DidStartSasVerification -> Unit
|
VerificationFlowState.DidStartSasVerification -> Unit
|
||||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidFinish -> {
|
VerificationFlowState.DidFinish -> {
|
||||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
|
stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidCancel -> {
|
VerificationFlowState.DidCancel -> {
|
||||||
// Can happen when:
|
// Can happen when:
|
||||||
|
|
@ -181,10 +203,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
||||||
// - another session has accepted the incoming verification request
|
// - 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
|
// - 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.
|
// machine will ignore this event and change state to Failure.
|
||||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
|
stateMachine.dispatch(StateMachineEvent.DidCancel)
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidFail -> {
|
VerificationFlowState.DidFail -> {
|
||||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail)
|
stateMachine.dispatch(StateMachineEvent.DidFail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
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.VerificationRequest
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class IncomingVerificationState(
|
data class IncomingVerificationState(
|
||||||
val step: Step,
|
val step: Step,
|
||||||
|
val request: VerificationRequest.Incoming,
|
||||||
val eventSink: (IncomingVerificationViewEvents) -> Unit,
|
val eventSink: (IncomingVerificationViewEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
@Stable
|
@Stable
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
init {
|
init {
|
||||||
spec {
|
spec {
|
||||||
inState<State.Initial> {
|
inState<State.Initial> {
|
||||||
on { _: Event.AcceptIncomingRequest, state ->
|
on<Event.AcceptIncomingRequest> { _, state ->
|
||||||
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
|
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,23 +39,23 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.ChallengeReceived> {
|
inState<State.ChallengeReceived> {
|
||||||
on { _: Event.AcceptChallenge, state ->
|
on<Event.AcceptChallenge> { _, state ->
|
||||||
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
|
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
|
||||||
}
|
}
|
||||||
on { _: Event.DeclineChallenge, state ->
|
on<Event.DeclineChallenge> { _, state ->
|
||||||
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
|
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.AcceptingChallenge> {
|
inState<State.AcceptingChallenge> {
|
||||||
onEnterEffect { _ ->
|
onEnterEffect {
|
||||||
sessionVerificationService.approveVerification()
|
sessionVerificationService.approveVerification()
|
||||||
}
|
}
|
||||||
on { _: Event.DidAcceptChallenge, state ->
|
on<Event.DidAcceptChallenge> { _, state ->
|
||||||
state.override { State.Completed.andLogStateChange() }
|
state.override { State.Completed.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.RejectingChallenge> {
|
inState<State.RejectingChallenge> {
|
||||||
onEnterEffect { _ ->
|
onEnterEffect {
|
||||||
sessionVerificationService.declineVerification()
|
sessionVerificationService.declineVerification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
inState {
|
inState {
|
||||||
logReceivedEvents()
|
logReceivedEvents()
|
||||||
on { _: Event.Cancel, state: MachineState<State> ->
|
on<Event.Cancel> { _, state: MachineState<State> ->
|
||||||
when (state.snapshot) {
|
when (state.snapshot) {
|
||||||
State.Completed, State.Canceled -> state.noChange()
|
State.Completed, State.Canceled -> state.noChange()
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -75,7 +75,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
on<Event.DidCancel> { _, state: MachineState<State> ->
|
||||||
when (state.snapshot) {
|
when (state.snapshot) {
|
||||||
is State.RejectingChallenge -> {
|
is State.RejectingChallenge -> {
|
||||||
state.override { State.Failure.andLogStateChange() }
|
state.override { State.Failure.andLogStateChange() }
|
||||||
|
|
@ -91,7 +91,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
State.Failure -> state.noChange()
|
State.Failure -> state.noChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on { _: Event.DidFail, state: MachineState<State> ->
|
on<Event.DidFail> { _, state: MachineState<State> ->
|
||||||
state.override { State.Failure.andLogStateChange() }
|
state.override { State.Failure.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +128,11 @@ class IncomingVerificationStateMachine @Inject constructor(
|
||||||
|
|
||||||
/** Verification failure. */
|
/** Verification failure. */
|
||||||
data object Failure : State
|
data object Failure : State
|
||||||
|
|
||||||
|
fun isPending(): Boolean = when (this) {
|
||||||
|
AcceptingIncomingVerification, RejectingIncomingVerification, Failure, is ChallengeReceived, is AcceptingChallenge, is RejectingChallenge -> true
|
||||||
|
is Initial, Canceling, Canceled, Completed -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,32 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
|
||||||
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
||||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
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 io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
|
open class IncomingVerificationStateProvider : PreviewParameterProvider<IncomingVerificationState> {
|
||||||
override val values: Sequence<IncomingVerificationState>
|
override val values: Sequence<IncomingVerificationState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
anIncomingVerificationState(),
|
anIncomingVerificationState(),
|
||||||
anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
|
anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingSessionVerificationRequest()),
|
||||||
|
anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingUserVerificationRequest()),
|
||||||
|
anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingSessionVerificationRequest()),
|
||||||
|
anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingUserVerificationRequest()),
|
||||||
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
|
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
|
||||||
|
anIncomingVerificationState(
|
||||||
|
step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false),
|
||||||
|
verificationRequest = anIncomingUserVerificationRequest()
|
||||||
|
),
|
||||||
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
|
anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
|
||||||
|
anIncomingVerificationState(
|
||||||
|
step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true),
|
||||||
|
verificationRequest = anIncomingUserVerificationRequest()
|
||||||
|
),
|
||||||
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
|
anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
|
||||||
anIncomingVerificationState(step = Step.Completed),
|
anIncomingVerificationState(step = Step.Completed),
|
||||||
|
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
|
||||||
anIncomingVerificationState(step = Step.Failure),
|
anIncomingVerificationState(step = Step.Failure),
|
||||||
anIncomingVerificationState(step = Step.Canceled),
|
anIncomingVerificationState(step = Step.Canceled),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
|
|
@ -37,10 +53,38 @@ internal fun aStepInitial(
|
||||||
isWaiting = isWaiting,
|
isWaiting = isWaiting,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession(
|
||||||
|
details = SessionVerificationRequestDetails(
|
||||||
|
senderProfile = SessionVerificationRequestDetails.SenderProfile(
|
||||||
|
userId = UserId("@alice:example.com"),
|
||||||
|
displayName = "Alice",
|
||||||
|
avatarUrl = null,
|
||||||
|
),
|
||||||
|
flowId = FlowId("1234"),
|
||||||
|
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||||
|
firstSeenTimestamp = 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
|
||||||
|
details = SessionVerificationRequestDetails(
|
||||||
|
senderProfile = SessionVerificationRequestDetails.SenderProfile(
|
||||||
|
userId = UserId("@alice:example.com"),
|
||||||
|
displayName = "Alice",
|
||||||
|
avatarUrl = null,
|
||||||
|
),
|
||||||
|
flowId = FlowId("1234"),
|
||||||
|
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||||
|
firstSeenTimestamp = 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
internal fun anIncomingVerificationState(
|
internal fun anIncomingVerificationState(
|
||||||
step: Step = aStepInitial(),
|
step: Step = aStepInitial(),
|
||||||
|
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest(),
|
||||||
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
|
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
|
||||||
) = IncomingVerificationState(
|
) = IncomingVerificationState(
|
||||||
step = step,
|
step = step,
|
||||||
|
request = verificationRequest,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,19 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
|
||||||
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
|
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.VerificationBottomMenu
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||||
|
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
|
||||||
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
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.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.VerificationRequest
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,10 +61,17 @@ fun IncomingVerificationView(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
when {
|
||||||
|
step is Step.Initial && !step.isWaiting -> Unit
|
||||||
|
step is Step.Completed -> Unit
|
||||||
|
else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
header = {
|
header = {
|
||||||
IncomingVerificationHeader(step = step)
|
IncomingVerificationHeader(step = step, request = state.request)
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
IncomingVerificationBottomMenu(
|
IncomingVerificationBottomMenu(
|
||||||
|
|
@ -72,37 +81,58 @@ fun IncomingVerificationView(
|
||||||
) {
|
) {
|
||||||
IncomingVerificationContent(
|
IncomingVerificationContent(
|
||||||
step = step,
|
step = step,
|
||||||
|
request = state.request,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IncomingVerificationHeader(step: Step) {
|
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
|
||||||
val iconStyle = when (step) {
|
val iconStyle = when (step) {
|
||||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||||
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
is Step.Initial -> if (step.isWaiting) {
|
||||||
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
BigIcon.Style.Loading
|
||||||
|
} else {
|
||||||
|
when (request) {
|
||||||
|
is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||||
|
is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Step.Verifying -> if (step.isWaiting) {
|
||||||
|
BigIcon.Style.Loading
|
||||||
|
} else {
|
||||||
|
BigIcon.Style.Default(CompoundIcons.ReactionSolid())
|
||||||
|
}
|
||||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||||
Step.Failure -> BigIcon.Style.AlertSolid
|
Step.Failure -> BigIcon.Style.AlertSolid
|
||||||
}
|
}
|
||||||
val titleTextId = when (step) {
|
val titleTextId = when (step) {
|
||||||
Step.Canceled -> R.string.screen_session_verification_request_failure_title
|
Step.Canceled -> CommonStrings.common_verification_failed
|
||||||
is Step.Initial -> R.string.screen_session_verification_request_title
|
is Step.Initial -> R.string.screen_session_verification_request_title
|
||||||
is Step.Verifying -> when (step.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
|
||||||
}
|
}
|
||||||
Step.Completed -> R.string.screen_session_verification_request_success_title
|
Step.Completed -> CommonStrings.common_verification_complete
|
||||||
Step.Failure -> R.string.screen_session_verification_request_failure_title
|
Step.Failure -> R.string.screen_session_verification_request_failure_title
|
||||||
}
|
}
|
||||||
val subtitleTextId = when (step) {
|
val subtitleTextId = when (step) {
|
||||||
Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
|
Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
|
||||||
is Step.Initial -> R.string.screen_session_verification_request_subtitle
|
is Step.Initial -> when (request) {
|
||||||
|
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle
|
||||||
|
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_user_responder_subtitle
|
||||||
|
}
|
||||||
is Step.Verifying -> when (step.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 -> when (request) {
|
||||||
|
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||||
|
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_compare_emojis_user_subtitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Step.Completed -> when (request) {
|
||||||
|
is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_complete_subtitle
|
||||||
|
is VerificationRequest.Incoming.User -> R.string.screen_session_verification_complete_user_subtitle
|
||||||
}
|
}
|
||||||
Step.Completed -> R.string.screen_session_verification_request_success_subtitle
|
|
||||||
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
|
Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
|
||||||
}
|
}
|
||||||
PageTitle(
|
PageTitle(
|
||||||
|
|
@ -115,9 +145,10 @@ private fun IncomingVerificationHeader(step: Step) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun IncomingVerificationContent(
|
private fun IncomingVerificationContent(
|
||||||
step: Step,
|
step: Step,
|
||||||
|
request: VerificationRequest.Incoming,
|
||||||
) {
|
) {
|
||||||
when (step) {
|
when (step) {
|
||||||
is Step.Initial -> ContentInitial(step)
|
is Step.Initial -> ContentInitial(step, request)
|
||||||
is Step.Verifying -> VerificationContentVerifying(step.data)
|
is Step.Verifying -> VerificationContentVerifying(step.data)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
@ -126,24 +157,40 @@ private fun IncomingVerificationContent(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentInitial(
|
private fun ContentInitial(
|
||||||
initialIncoming: Step.Initial,
|
initialIncoming: Step.Initial,
|
||||||
|
request: VerificationRequest.Incoming,
|
||||||
) {
|
) {
|
||||||
Column(
|
when (request) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
is VerificationRequest.Incoming.OtherSession -> {
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
Column(
|
||||||
) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
SessionDetailsView(
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
deviceName = initialIncoming.deviceDisplayName,
|
) {
|
||||||
deviceId = initialIncoming.deviceId,
|
SessionDetailsView(
|
||||||
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
|
deviceName = initialIncoming.deviceDisplayName,
|
||||||
)
|
deviceId = initialIncoming.deviceId,
|
||||||
Text(
|
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
|
||||||
modifier = Modifier
|
)
|
||||||
.align(Alignment.CenterHorizontally)
|
Text(
|
||||||
.padding(bottom = 16.dp),
|
modifier = Modifier
|
||||||
text = stringResource(R.string.screen_session_verification_request_footer),
|
.align(Alignment.CenterHorizontally)
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
.padding(bottom = 16.dp),
|
||||||
textAlign = TextAlign.Center,
|
text = stringResource(R.string.screen_session_verification_request_footer),
|
||||||
)
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is VerificationRequest.Incoming.User -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||||
|
) {
|
||||||
|
VerificationUserProfileContent(
|
||||||
|
userId = request.details.senderProfile.userId,
|
||||||
|
displayName = request.details.senderProfile.displayName,
|
||||||
|
avatarUrl = request.details.senderProfile.avatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,16 +204,7 @@ private fun IncomingVerificationBottomMenu(
|
||||||
when (step) {
|
when (step) {
|
||||||
is Step.Initial -> {
|
is Step.Initial -> {
|
||||||
if (step.isWaiting) {
|
if (step.isWaiting) {
|
||||||
VerificationBottomMenu {
|
// Show nothing
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
showProgress = true,
|
|
||||||
)
|
|
||||||
InvisibleButton()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
VerificationBottomMenu {
|
VerificationBottomMenu {
|
||||||
Button(
|
Button(
|
||||||
|
|
@ -184,16 +222,7 @@ private fun IncomingVerificationBottomMenu(
|
||||||
}
|
}
|
||||||
is Step.Verifying -> {
|
is Step.Verifying -> {
|
||||||
if (step.isWaiting) {
|
if (step.isWaiting) {
|
||||||
VerificationBottomMenu {
|
// Show nothing
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
showProgress = true,
|
|
||||||
)
|
|
||||||
InvisibleButton()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
VerificationBottomMenu {
|
VerificationBottomMenu {
|
||||||
Button(
|
Button(
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
|
|
||||||
package io.element.android.features.verifysession.impl.outgoing
|
package io.element.android.features.verifysession.impl.outgoing
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.activity.compose.LocalActivity
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
|
@ -18,10 +16,7 @@ import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appconfig.LearnMoreConfig
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
|
@ -33,28 +28,22 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
|
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
|
||||||
|
|
||||||
private val presenter = presenterFactory.create(
|
private val inputs = inputs<VerifySessionEntryPoint.Params>()
|
||||||
showDeviceVerifiedScreen = inputs<VerifySessionEntryPoint.Params>().showDeviceVerifiedScreen,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun onLearnMoreClick(activity: Activity, dark: Boolean) {
|
private val presenter = presenterFactory.create(
|
||||||
activity.openUrlInChromeCustomTab(null, dark, LearnMoreConfig.ENCRYPTION_URL)
|
showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen,
|
||||||
}
|
verificationRequest = inputs.verificationRequest,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
val activity = requireNotNull(LocalActivity.current)
|
|
||||||
val isDark = ElementTheme.isLightTheme.not()
|
|
||||||
VerifySelfSessionView(
|
VerifySelfSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onLearnMoreClick = {
|
onLearnMoreClick = callback::onLearnMoreAboutEncryption,
|
||||||
onLearnMoreClick(activity, isDark)
|
|
||||||
},
|
|
||||||
onEnterRecoveryKey = callback::onEnterRecoveryKey,
|
|
||||||
onResetKey = callback::onResetKey,
|
|
||||||
onFinish = callback::onDone,
|
onFinish = callback::onDone,
|
||||||
|
onBack = callback::onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,137 +11,113 @@ 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
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.logout.api.LogoutUseCase
|
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
|
||||||
import io.element.android.libraries.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.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.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
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 timber.log.Timber
|
import timber.log.Timber
|
||||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
|
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,
|
||||||
|
@Assisted private val verificationRequest: VerificationRequest.Outgoing,
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
private val stateMachine: VerifySelfSessionStateMachine,
|
|
||||||
private val buildMeta: BuildMeta,
|
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
|
||||||
private val logoutUseCase: LogoutUseCase,
|
|
||||||
) : Presenter<VerifySelfSessionState> {
|
) : Presenter<VerifySelfSessionState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(showDeviceVerifiedScreen: Boolean): VerifySelfSessionPresenter
|
fun create(
|
||||||
|
verificationRequest: VerificationRequest.Outgoing,
|
||||||
|
showDeviceVerifiedScreen: Boolean,
|
||||||
|
): VerifySelfSessionPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val stateMachine = VerifySelfSessionStateMachine(
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
encryptionService = encryptionService,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): VerifySelfSessionState {
|
override fun present(): VerifySelfSessionState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// Force reset, just in case the service was left in a broken state
|
|
||||||
sessionVerificationService.reset(true)
|
|
||||||
}
|
|
||||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
|
||||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||||
val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
|
|
||||||
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
||||||
val signOutAction = remember {
|
|
||||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
|
||||||
}
|
|
||||||
val step by remember {
|
val step by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (skipVerification) {
|
when (verificationRequest) {
|
||||||
VerifySelfSessionState.Step.Skipped
|
is VerificationRequest.Outgoing.CurrentSession -> {
|
||||||
} else {
|
when (sessionVerifiedStatus) {
|
||||||
when (sessionVerifiedStatus) {
|
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
SessionVerifiedStatus.NotVerified -> {
|
||||||
SessionVerifiedStatus.NotVerified -> {
|
stateAndDispatch.state.value.toVerificationStep()
|
||||||
stateAndDispatch.state.value.toVerificationStep(
|
}
|
||||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
SessionVerifiedStatus.Verified -> {
|
||||||
)
|
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
||||||
}
|
// The user has verified the session, we need to show the success screen
|
||||||
SessionVerifiedStatus.Verified -> {
|
VerifySelfSessionState.Step.Completed
|
||||||
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
} else {
|
||||||
// The user has verified the session, we need to show the success screen
|
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
||||||
VerifySelfSessionState.Step.Completed
|
VerifySelfSessionState.Step.Exit
|
||||||
} else {
|
}
|
||||||
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
|
||||||
VerifySelfSessionState.Step.Skipped
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is VerificationRequest.Outgoing.User -> stateAndDispatch.state.value.toVerificationStep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start this after observing state machine
|
// Start this after observing state machine
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Force reset, just in case the service was left in a broken state
|
||||||
|
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = true)
|
||||||
|
|
||||||
observeVerificationService()
|
observeVerificationService()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvents(event: VerifySelfSessionViewEvents) {
|
fun handleEvents(event: VerifySelfSessionViewEvents) {
|
||||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||||
when (event) {
|
when (event) {
|
||||||
VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
|
// Just relay the event to the state machine
|
||||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification(verificationRequest))
|
||||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||||
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
|
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
|
||||||
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
||||||
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
|
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
|
||||||
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
|
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
|
||||||
VerifySelfSessionViewEvents.SignOut -> coroutineScope.signOut(signOutAction)
|
|
||||||
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
|
|
||||||
sessionPreferencesStore.setSkipSessionVerification(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return VerifySelfSessionState(
|
return VerifySelfSessionState(
|
||||||
step = step,
|
step = step,
|
||||||
signOutAction = signOutAction.value,
|
request = verificationRequest,
|
||||||
displaySkipButton = buildMeta.isDebuggable,
|
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun StateMachineState?.toVerificationStep(
|
private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.Step =
|
||||||
canEnterRecoveryKey: Boolean
|
|
||||||
): VerifySelfSessionState.Step =
|
|
||||||
when (val machineState = this) {
|
when (val machineState = this) {
|
||||||
StateMachineState.Initial, null -> {
|
StateMachineState.Initial, null -> {
|
||||||
VerifySelfSessionState.Step.Initial(
|
VerifySelfSessionState.Step.Initial
|
||||||
canEnterRecoveryKey = canEnterRecoveryKey,
|
|
||||||
isLastDevice = encryptionService.isLastDevice.value
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
|
is StateMachineState.RequestingVerification,
|
||||||
VerifySelfSessionState.Step.UseAnotherDevice
|
is StateMachineState.StartingSasVerification,
|
||||||
}
|
StateMachineState.SasVerificationStarted -> {
|
||||||
StateMachineState.RequestingVerification,
|
|
||||||
StateMachineState.StartingSasVerification,
|
|
||||||
StateMachineState.SasVerificationStarted,
|
|
||||||
StateMachineState.Canceling -> {
|
|
||||||
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +125,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||||
VerifySelfSessionState.Step.Ready
|
VerifySelfSessionState.Step.Ready
|
||||||
}
|
}
|
||||||
|
|
||||||
StateMachineState.Canceled -> {
|
is StateMachineState.Canceled -> {
|
||||||
VerifySelfSessionState.Step.Canceled
|
VerifySelfSessionState.Step.Canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +140,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||||
StateMachineState.Completed -> {
|
StateMachineState.Completed -> {
|
||||||
VerifySelfSessionState.Step.Completed
|
VerifySelfSessionState.Step.Completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StateMachineState.Exit -> {
|
||||||
|
VerifySelfSessionState.Step.Exit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.observeVerificationService() {
|
private fun CoroutineScope.observeVerificationService() {
|
||||||
|
|
@ -171,33 +151,27 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||||
.onEach { verificationAttemptState ->
|
.onEach { verificationAttemptState ->
|
||||||
when (verificationAttemptState) {
|
when (verificationAttemptState) {
|
||||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
VerificationFlowState.Initial -> stateMachine.dispatch(StateMachineEvent.Reset)
|
||||||
VerificationFlowState.DidAcceptVerificationRequest -> {
|
VerificationFlowState.DidAcceptVerificationRequest -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
stateMachine.dispatch(StateMachineEvent.DidAcceptVerificationRequest)
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidStartSasVerification -> {
|
VerificationFlowState.DidStartSasVerification -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
stateMachine.dispatch(StateMachineEvent.DidStartSasVerification)
|
||||||
}
|
}
|
||||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidFinish -> {
|
VerificationFlowState.DidFinish -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidCancel -> {
|
VerificationFlowState.DidCancel -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
stateMachine.dispatch(StateMachineEvent.DidCancel)
|
||||||
}
|
}
|
||||||
VerificationFlowState.DidFail -> {
|
VerificationFlowState.DidFail -> {
|
||||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
stateMachine.dispatch(StateMachineEvent.DidFail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<Unit>>) = launch {
|
|
||||||
suspend {
|
|
||||||
logoutUseCase.logout(ignoreSdkError = true)
|
|
||||||
}.runCatchingUpdatingState(signOutAction)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,25 @@ 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
|
||||||
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.matrix.api.verification.SessionVerificationData
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class VerifySelfSessionState(
|
data class VerifySelfSessionState(
|
||||||
val step: Step,
|
val step: Step,
|
||||||
val signOutAction: AsyncAction<Unit>,
|
val request: VerificationRequest.Outgoing,
|
||||||
val displaySkipButton: Boolean,
|
|
||||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
@Stable
|
@Stable
|
||||||
sealed interface Step {
|
sealed interface Step {
|
||||||
data object Loading : Step
|
data object Loading : Step
|
||||||
|
data object Initial : Step
|
||||||
// FIXME canEnterRecoveryKey value is never read.
|
|
||||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
|
|
||||||
data object UseAnotherDevice : Step
|
|
||||||
data object Canceled : Step
|
data object Canceled : Step
|
||||||
data object AwaitingOtherDeviceResponse : Step
|
data object AwaitingOtherDeviceResponse : Step
|
||||||
data object Ready : Step
|
data object Ready : Step
|
||||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
||||||
data object Completed : Step
|
data object Completed : Step
|
||||||
data object Skipped : Step
|
data object Exit : Step
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,39 +19,37 @@ 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.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.timeout
|
import kotlinx.coroutines.flow.timeout
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import com.freeletics.flowredux.dsl.State as MachineState
|
import com.freeletics.flowredux.dsl.State as MachineState
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class VerifySelfSessionStateMachine @Inject constructor(
|
class VerifySelfSessionStateMachine(
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
|
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
|
||||||
initialState = State.Initial
|
initialState = State.Initial,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
spec {
|
spec {
|
||||||
inState<State.Initial> {
|
inState<State.Initial> {
|
||||||
on { _: Event.UseAnotherDevice, state ->
|
on<Event.RequestVerification> { event, state ->
|
||||||
state.override { State.UseAnotherDevice.andLogStateChange() }
|
state.override { State.RequestingVerification(event.verificationRequest).andLogStateChange() }
|
||||||
}
|
|
||||||
}
|
|
||||||
inState<State.UseAnotherDevice> {
|
|
||||||
on { _: Event.RequestVerification, state ->
|
|
||||||
state.override { State.RequestingVerification.andLogStateChange() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.RequestingVerification> {
|
inState<State.RequestingVerification> {
|
||||||
onEnterEffect {
|
onEnterEffect { event ->
|
||||||
sessionVerificationService.requestVerification()
|
when (event.verificationRequest) {
|
||||||
|
is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestCurrentSessionVerification()
|
||||||
|
is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(event.verificationRequest.userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
on { _: Event.DidAcceptVerificationRequest, state ->
|
on<Event.DidAcceptVerificationRequest> { _, state ->
|
||||||
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,25 +59,26 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.VerificationRequestAccepted> {
|
inState<State.VerificationRequestAccepted> {
|
||||||
on { _: Event.StartSasVerification, state ->
|
on<Event.StartSasVerification> { _, state ->
|
||||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.Canceled> {
|
inState<State.Canceled> {
|
||||||
on { _: Event.Reset, state ->
|
on<Event.Reset> { _, state ->
|
||||||
|
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = false)
|
||||||
state.override { State.Initial.andLogStateChange() }
|
state.override { State.Initial.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.SasVerificationStarted> {
|
inState<State.SasVerificationStarted> {
|
||||||
on { event: Event.DidReceiveChallenge, state ->
|
on<Event.DidReceiveChallenge> { event, state ->
|
||||||
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
|
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).andLogStateChange() }
|
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).andLogStateChange() }
|
state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +90,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||||
sessionVerificationService.declineVerification()
|
sessionVerificationService.declineVerification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on { _: Event.DidAcceptChallenge, state ->
|
on<Event.DidAcceptChallenge> { _, state ->
|
||||||
// If a key backup exists, wait until it's restored or a timeout happens
|
// If a key backup exists, wait until it's restored or a timeout happens
|
||||||
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
|
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
|
||||||
if (hasBackup) {
|
if (hasBackup) {
|
||||||
|
|
@ -104,21 +103,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||||
state.override { State.Completed.andLogStateChange() }
|
state.override { State.Completed.andLogStateChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.Canceling> {
|
|
||||||
// TODO The 'Canceling' -> 'Canceled' transitions doesn't seem to work anymore, check if something changed in the Rust SDK
|
|
||||||
onEnterEffect {
|
|
||||||
sessionVerificationService.cancelVerification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inState {
|
inState {
|
||||||
logReceivedEvents()
|
logReceivedEvents()
|
||||||
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
|
on<Event.DidStartSasVerification> { _, state: MachineState<State> ->
|
||||||
state.override { State.SasVerificationStarted.andLogStateChange() }
|
state.override { State.SasVerificationStarted.andLogStateChange() }
|
||||||
}
|
}
|
||||||
on { _: Event.Cancel, state: MachineState<State> ->
|
on<Event.Cancel> { event, state: MachineState<State> ->
|
||||||
when (state.snapshot) {
|
when (state.snapshot) {
|
||||||
State.Initial, State.Completed, State.Canceled -> state.noChange()
|
State.Initial, State.Completed, is State.Canceled -> state.override { State.Exit }
|
||||||
State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
|
|
||||||
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
|
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
|
||||||
// `Canceling` state to `Canceled` automatically anymore
|
// `Canceling` state to `Canceled` automatically anymore
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -127,28 +119,22 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
on<Event.DidCancel> { event, state: MachineState<State> ->
|
||||||
state.override { State.Canceled.andLogStateChange() }
|
state.override { State.Canceled.andLogStateChange() }
|
||||||
}
|
}
|
||||||
on { _: Event.DidFail, state: MachineState<State> ->
|
on<Event.DidFail> { event, state: MachineState<State> ->
|
||||||
when (state.snapshot) {
|
state.override { State.Canceled.andLogStateChange() }
|
||||||
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
|
|
||||||
else -> state.override { State.Canceled.andLogStateChange() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface State {
|
sealed interface State {
|
||||||
/** The initial state, before verification started. */
|
/** Let the user know that they need to get ready on their other session. */
|
||||||
data object Initial : State
|
data object Initial : State
|
||||||
|
|
||||||
/** Let the user know that they need to get ready on their other session. */
|
|
||||||
data object UseAnotherDevice : State
|
|
||||||
|
|
||||||
/** Waiting for verification acceptance. */
|
/** Waiting for verification acceptance. */
|
||||||
data object RequestingVerification : State
|
data class RequestingVerification(val verificationRequest: VerificationRequest.Outgoing) : State
|
||||||
|
|
||||||
/** Verification request accepted. Waiting for start. */
|
/** Verification request accepted. Waiting for start. */
|
||||||
data object VerificationRequestAccepted : State
|
data object VerificationRequestAccepted : State
|
||||||
|
|
@ -167,22 +153,18 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
||||||
data class Replying(override val data: SessionVerificationData, val accept: Boolean) : Verifying(data)
|
data class Replying(override val data: SessionVerificationData, val accept: Boolean) : Verifying(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The verification is being canceled. */
|
|
||||||
data object Canceling : State
|
|
||||||
|
|
||||||
/** The verification has been canceled, remotely or locally. */
|
/** The verification has been canceled, remotely or locally. */
|
||||||
data object Canceled : State
|
data object Canceled : State
|
||||||
|
|
||||||
/** Verification successful. */
|
/** Verification successful. */
|
||||||
data object Completed : State
|
data object Completed : State
|
||||||
|
|
||||||
|
data object Exit : State
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
/** User wants to use another session. */
|
|
||||||
data object UseAnotherDevice : Event
|
|
||||||
|
|
||||||
/** Request verification. */
|
/** Request verification. */
|
||||||
data object RequestVerification : Event
|
data class RequestVerification(val verificationRequest: VerificationRequest.Outgoing) : Event
|
||||||
|
|
||||||
/** The current verification request has been accepted. */
|
/** The current verification request has been accepted. */
|
||||||
data object DidAcceptVerificationRequest : Event
|
data object DidAcceptVerificationRequest : Event
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,36 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
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.aDecimalsSessionVerificationData
|
||||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
||||||
override val values: Sequence<VerifySelfSessionState>
|
override val values: Sequence<VerifySelfSessionState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aVerifySelfSessionState(displaySkipButton = true),
|
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.AwaitingOtherDeviceResponse
|
step = Step.Initial,
|
||||||
|
request = anOutgoingSessionVerificationRequest(),
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
|
step = Step.Initial,
|
||||||
|
request = anOutgoingUserVerificationRequest(),
|
||||||
|
),
|
||||||
|
aVerifySelfSessionState(
|
||||||
|
step = Step.AwaitingOtherDeviceResponse,
|
||||||
|
request = anOutgoingSessionVerificationRequest(),
|
||||||
|
),
|
||||||
|
aVerifySelfSessionState(
|
||||||
|
step = Step.AwaitingOtherDeviceResponse,
|
||||||
|
request = anOutgoingUserVerificationRequest(),
|
||||||
|
),
|
||||||
|
aVerifySelfSessionState(
|
||||||
|
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
|
||||||
|
request = anOutgoingSessionVerificationRequest(),
|
||||||
|
),
|
||||||
|
aVerifySelfSessionState(
|
||||||
|
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
|
||||||
|
request = anOutgoingUserVerificationRequest(),
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||||
|
|
@ -37,40 +55,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
|
||||||
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Initial(canEnterRecoveryKey = true)
|
step = Step.Completed,
|
||||||
),
|
request = anOutgoingSessionVerificationRequest(),
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
|
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Completed,
|
step = Step.Completed,
|
||||||
displaySkipButton = true,
|
request = anOutgoingUserVerificationRequest(),
|
||||||
),
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
signOutAction = AsyncAction.Loading,
|
|
||||||
displaySkipButton = true,
|
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Loading
|
step = Step.Loading
|
||||||
),
|
),
|
||||||
aVerifySelfSessionState(
|
aVerifySelfSessionState(
|
||||||
step = Step.Skipped
|
step = Step.Exit
|
||||||
),
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = Step.UseAnotherDevice
|
|
||||||
),
|
),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com"))
|
||||||
|
internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession
|
||||||
|
|
||||||
internal fun aVerifySelfSessionState(
|
internal fun aVerifySelfSessionState(
|
||||||
step: Step = Step.Initial(canEnterRecoveryKey = false),
|
step: Step = Step.Initial,
|
||||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
|
||||||
displaySkipButton: Boolean = false,
|
|
||||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
||||||
) = VerifySelfSessionState(
|
) = VerifySelfSessionState(
|
||||||
step = step,
|
step = step,
|
||||||
displaySkipButton = displaySkipButton,
|
request = request,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
signOutAction = signOutAction,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
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.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -32,22 +28,21 @@ 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.outgoing.VerifySelfSessionState.Step
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
|
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.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.VerificationRequest
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -55,18 +50,16 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun VerifySelfSessionView(
|
fun VerifySelfSessionView(
|
||||||
state: VerifySelfSessionState,
|
state: VerifySelfSessionState,
|
||||||
onLearnMoreClick: () -> Unit,
|
onLearnMoreClick: () -> Unit,
|
||||||
onEnterRecoveryKey: () -> Unit,
|
|
||||||
onResetKey: () -> Unit,
|
|
||||||
onFinish: () -> Unit,
|
onFinish: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val step = state.step
|
val step = state.step
|
||||||
fun cancelOrResetFlow() {
|
fun cancelOrResetFlow() {
|
||||||
when (step) {
|
when (step) {
|
||||||
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||||
is Step.AwaitingOtherDeviceResponse,
|
Step.Initial, Step.Completed -> onBack()
|
||||||
Step.UseAnotherDevice,
|
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||||
Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
|
||||||
is Step.Verifying -> {
|
is Step.Verifying -> {
|
||||||
if (!step.state.isLoading()) {
|
if (!step.state.isLoading()) {
|
||||||
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
|
||||||
|
|
@ -76,18 +69,11 @@ fun VerifySelfSessionView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
|
|
||||||
LaunchedEffect(step, latestOnFinish) {
|
|
||||||
if (step is Step.Skipped) {
|
|
||||||
latestOnFinish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
cancelOrResetFlow()
|
cancelOrResetFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step is Step.Loading ||
|
if (step is Step.Loading) {
|
||||||
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(),
|
||||||
|
|
@ -101,94 +87,94 @@ fun VerifySelfSessionView(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
actions = {
|
navigationIcon = if (step != Step.Completed) {
|
||||||
if (step !is Step.Completed &&
|
{ BackButton(onClick = ::cancelOrResetFlow) }
|
||||||
state.displaySkipButton &&
|
} else {
|
||||||
LocalInspectionMode.current.not()) {
|
{}
|
||||||
TextButton(
|
|
||||||
text = stringResource(CommonStrings.action_skip),
|
|
||||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (step is Step.Initial) {
|
|
||||||
TextButton(
|
|
||||||
text = stringResource(CommonStrings.action_signout),
|
|
||||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
header = {
|
header = {
|
||||||
VerifySelfSessionHeader(step = step)
|
VerifySelfSessionHeader(step = step, request = state.request)
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
VerifySelfSessionBottomMenu(
|
VerifySelfSessionBottomMenu(
|
||||||
screenState = state,
|
screenState = state,
|
||||||
onCancelClick = ::cancelOrResetFlow,
|
onCancelClick = ::cancelOrResetFlow,
|
||||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
|
||||||
onContinueClick = onFinish,
|
onContinueClick = onFinish,
|
||||||
onResetKey = onResetKey,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
VerifySelfSessionContent(
|
VerifySelfSessionContent(
|
||||||
flowState = step,
|
flowState = step,
|
||||||
|
request = state.request,
|
||||||
onLearnMoreClick = onLearnMoreClick,
|
onLearnMoreClick = onLearnMoreClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.signOutAction) {
|
|
||||||
AsyncAction.Loading -> {
|
|
||||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
|
||||||
}
|
|
||||||
is AsyncAction.Success,
|
|
||||||
is AsyncAction.Confirming,
|
|
||||||
is AsyncAction.Failure,
|
|
||||||
AsyncAction.Uninitialized -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerifySelfSessionHeader(step: Step) {
|
private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Outgoing) {
|
||||||
val iconStyle = when (step) {
|
val iconStyle = when (step) {
|
||||||
Step.Loading -> error("Should not happen")
|
Step.Loading -> error("Should not happen")
|
||||||
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
Step.Initial -> when (request) {
|
||||||
Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
|
is VerificationRequest.Outgoing.CurrentSession -> BigIcon.Style.Default(CompoundIcons.Devices())
|
||||||
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
|
is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||||
|
}
|
||||||
|
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Loading
|
||||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||||
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
Step.Ready -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||||
Step.Completed -> BigIcon.Style.SuccessSolid
|
Step.Completed -> BigIcon.Style.SuccessSolid
|
||||||
is Step.Skipped -> return
|
is Step.Verifying -> {
|
||||||
|
if (step.state is AsyncData.Loading<Unit>) {
|
||||||
|
BigIcon.Style.Loading
|
||||||
|
} else {
|
||||||
|
BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Step.Exit -> return
|
||||||
}
|
}
|
||||||
val titleTextId = when (step) {
|
val titleTextId = when (step) {
|
||||||
Step.Loading -> error("Should not happen")
|
Step.Loading -> error("Should not happen")
|
||||||
is Step.Initial -> R.string.screen_identity_confirmation_title
|
Step.Initial -> when (request) {
|
||||||
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
|
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_title
|
||||||
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
|
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_title
|
||||||
|
}
|
||||||
|
Step.AwaitingOtherDeviceResponse -> when (request) {
|
||||||
|
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_waiting_another_device_title
|
||||||
|
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_waiting_other_user_title
|
||||||
|
}
|
||||||
Step.Canceled -> CommonStrings.common_verification_failed
|
Step.Canceled -> CommonStrings.common_verification_failed
|
||||||
Step.Ready -> R.string.screen_session_verification_compare_emojis_title
|
Step.Ready -> R.string.screen_session_verification_compare_emojis_title
|
||||||
Step.Completed -> R.string.screen_identity_confirmed_title
|
Step.Completed -> CommonStrings.common_verification_complete
|
||||||
is Step.Verifying -> when (step.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 Step.Skipped -> return
|
is Step.Exit -> return
|
||||||
}
|
}
|
||||||
val subtitleTextId = when (step) {
|
val subtitleTextId = when (step) {
|
||||||
Step.Loading -> error("Should not happen")
|
Step.Loading -> error("Should not happen")
|
||||||
is Step.Initial -> R.string.screen_identity_confirmation_subtitle
|
Step.Initial -> when (request) {
|
||||||
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
|
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_subtitle
|
||||||
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
|
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_subtitle
|
||||||
|
}
|
||||||
|
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_subtitle
|
||||||
Step.Canceled -> R.string.screen_session_verification_failed_subtitle
|
Step.Canceled -> R.string.screen_session_verification_failed_subtitle
|
||||||
Step.Ready -> R.string.screen_session_verification_ready_subtitle
|
Step.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||||
Step.Completed -> R.string.screen_identity_confirmed_subtitle
|
Step.Completed -> when (request) {
|
||||||
|
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_identity_confirmed_subtitle
|
||||||
|
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_complete_user_subtitle
|
||||||
|
}
|
||||||
is Step.Verifying -> when (step.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 -> when (request) {
|
||||||
|
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||||
|
is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_compare_emojis_user_subtitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Step.Skipped -> return
|
is Step.Exit -> return
|
||||||
}
|
}
|
||||||
|
|
||||||
PageTitle(
|
PageTitle(
|
||||||
|
|
@ -201,11 +187,15 @@ private fun VerifySelfSessionHeader(step: Step) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerifySelfSessionContent(
|
private fun VerifySelfSessionContent(
|
||||||
flowState: Step,
|
flowState: Step,
|
||||||
|
request: VerificationRequest.Outgoing,
|
||||||
onLearnMoreClick: () -> Unit,
|
onLearnMoreClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (flowState) {
|
when (flowState) {
|
||||||
is Step.Initial -> {
|
is Step.Initial -> {
|
||||||
ContentInitial(onLearnMoreClick)
|
when (request) {
|
||||||
|
is VerificationRequest.Outgoing.CurrentSession -> Unit
|
||||||
|
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Step.Verifying -> {
|
is Step.Verifying -> {
|
||||||
VerificationContentVerifying(flowState.data)
|
VerificationContentVerifying(flowState.data)
|
||||||
|
|
@ -235,8 +225,6 @@ private fun ContentInitial(
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerifySelfSessionBottomMenu(
|
private fun VerifySelfSessionBottomMenu(
|
||||||
screenState: VerifySelfSessionState,
|
screenState: VerifySelfSessionState,
|
||||||
onEnterRecoveryKey: () -> Unit,
|
|
||||||
onResetKey: () -> Unit,
|
|
||||||
onCancelClick: () -> Unit,
|
onCancelClick: () -> Unit,
|
||||||
onContinueClick: () -> Unit,
|
onContinueClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -248,27 +236,6 @@ private fun VerifySelfSessionBottomMenu(
|
||||||
when (verificationViewState) {
|
when (verificationViewState) {
|
||||||
Step.Loading -> error("Should not happen")
|
Step.Loading -> error("Should not happen")
|
||||||
is Step.Initial -> {
|
is Step.Initial -> {
|
||||||
VerificationBottomMenu {
|
|
||||||
if (verificationViewState.isLastDevice.not()) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_identity_use_another_device),
|
|
||||||
onClick = { eventSink(VerifySelfSessionViewEvents.UseAnotherDevice) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
|
||||||
onClick = onEnterRecoveryKey,
|
|
||||||
)
|
|
||||||
OutlinedButton(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
|
|
||||||
onClick = onResetKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Step.UseAnotherDevice -> {
|
|
||||||
VerificationBottomMenu {
|
VerificationBottomMenu {
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -302,39 +269,20 @@ private fun VerifySelfSessionBottomMenu(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Step.AwaitingOtherDeviceResponse -> {
|
is Step.AwaitingOtherDeviceResponse -> Unit
|
||||||
VerificationBottomMenu {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
|
||||||
onClick = {},
|
|
||||||
showProgress = true,
|
|
||||||
enabled = false,
|
|
||||||
)
|
|
||||||
InvisibleButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Step.Verifying -> {
|
is Step.Verifying -> {
|
||||||
val positiveButtonTitle = if (isVerifying) {
|
if (isVerifying) {
|
||||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
// Show nothing
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.screen_session_verification_they_match)
|
VerificationBottomMenu {
|
||||||
}
|
Button(
|
||||||
VerificationBottomMenu {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Button(
|
text = stringResource(R.string.screen_session_verification_they_match),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = {
|
||||||
text = positiveButtonTitle,
|
|
||||||
showProgress = isVerifying,
|
|
||||||
enabled = !isVerifying,
|
|
||||||
onClick = {
|
|
||||||
if (!isVerifying) {
|
|
||||||
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
)
|
|
||||||
if (isVerifying) {
|
|
||||||
InvisibleButton()
|
|
||||||
} else {
|
|
||||||
TextButton(
|
TextButton(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||||
|
|
@ -353,7 +301,7 @@ private fun VerifySelfSessionBottomMenu(
|
||||||
InvisibleButton()
|
InvisibleButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Step.Skipped -> return
|
is Step.Exit -> return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,8 +311,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
|
||||||
VerifySelfSessionView(
|
VerifySelfSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
onLearnMoreClick = {},
|
onLearnMoreClick = {},
|
||||||
onEnterRecoveryKey = {},
|
|
||||||
onResetKey = {},
|
|
||||||
onFinish = {},
|
onFinish = {},
|
||||||
|
onBack = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,10 @@
|
||||||
package io.element.android.features.verifysession.impl.outgoing
|
package io.element.android.features.verifysession.impl.outgoing
|
||||||
|
|
||||||
sealed interface VerifySelfSessionViewEvents {
|
sealed interface VerifySelfSessionViewEvents {
|
||||||
data object UseAnotherDevice : VerifySelfSessionViewEvents
|
|
||||||
data object RequestVerification : VerifySelfSessionViewEvents
|
data object RequestVerification : VerifySelfSessionViewEvents
|
||||||
data object StartSasVerification : VerifySelfSessionViewEvents
|
data object StartSasVerification : VerifySelfSessionViewEvents
|
||||||
data object ConfirmVerification : VerifySelfSessionViewEvents
|
data object ConfirmVerification : VerifySelfSessionViewEvents
|
||||||
data object DeclineVerification : VerifySelfSessionViewEvents
|
data object DeclineVerification : VerifySelfSessionViewEvents
|
||||||
data object Cancel : VerifySelfSessionViewEvents
|
data object Cancel : VerifySelfSessionViewEvents
|
||||||
data object Reset : VerifySelfSessionViewEvents
|
data object Reset : VerifySelfSessionViewEvents
|
||||||
data object SignOut : VerifySelfSessionViewEvents
|
|
||||||
data object SkipVerification : VerifySelfSessionViewEvents
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.verifysession.impl.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
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.UserId
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerificationUserProfileContent(
|
||||||
|
userId: UserId,
|
||||||
|
displayName: String?,
|
||||||
|
avatarUrl: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val avatarData = remember(userId, displayName, avatarUrl) {
|
||||||
|
AvatarData(id = userId.value, name = displayName, url = avatarUrl, size = AvatarSize.UserVerification)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Avatar(avatarData)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(12.dp))
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(text = displayName ?: userId.value, style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary)
|
||||||
|
|
||||||
|
if (displayName != null) {
|
||||||
|
Text(text = userId.value, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun VerificationUserProfileContentPreview() = ElementPreview {
|
||||||
|
VerificationUserProfileContent(
|
||||||
|
userId = UserId("@alice:example.com"),
|
||||||
|
displayName = "Alice",
|
||||||
|
avatarUrl = "https://example.com/avatar.png",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ 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.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.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
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_TIMESTAMP
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -25,6 +26,7 @@ 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 io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -37,7 +39,7 @@ class IncomingVerificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||||
val approveVerificationLambda = lambdaRecorder<Unit> { }
|
val approveVerificationLambda = lambdaRecorder<Unit> { }
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||||
|
|
@ -60,7 +62,7 @@ class IncomingVerificationPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||||
// User accept the incoming verification
|
// User accept the incoming verification
|
||||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||||
|
|
@ -100,7 +102,7 @@ class IncomingVerificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - emoji not matching case - incoming verification failure`() = runTest {
|
fun `present - emoji not matching case - incoming verification failure`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||||
|
|
@ -123,7 +125,7 @@ class IncomingVerificationPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||||
// User accept the incoming verification
|
// User accept the incoming verification
|
||||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||||
|
|
@ -157,7 +159,7 @@ class IncomingVerificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - incoming verification is remotely canceled`() = runTest {
|
fun `present - incoming verification is remotely canceled`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||||
|
|
@ -191,7 +193,7 @@ class IncomingVerificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
|
fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||||
|
|
@ -214,7 +216,7 @@ class IncomingVerificationPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||||
// User accept the incoming verification
|
// User accept the incoming verification
|
||||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||||
|
|
@ -248,7 +250,7 @@ class IncomingVerificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - user ignores incoming request`() = runTest {
|
fun `present - user ignores incoming request`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||||
|
|
@ -268,24 +270,30 @@ class IncomingVerificationPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
|
private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
|
||||||
senderId = A_USER_ID,
|
details = SessionVerificationRequestDetails(
|
||||||
flowId = FlowId("flowId"),
|
senderProfile = SessionVerificationRequestDetails.SenderProfile(
|
||||||
deviceId = A_DEVICE_ID,
|
userId = A_USER_ID,
|
||||||
displayName = "a device name",
|
displayName = "a device name",
|
||||||
firstSeenTimestamp = A_TIMESTAMP,
|
avatarUrl = null,
|
||||||
|
),
|
||||||
|
flowId = FlowId("flowId"),
|
||||||
|
deviceId = A_DEVICE_ID,
|
||||||
|
firstSeenTimestamp = A_TIMESTAMP,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun TestScope.createPresenter(
|
||||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
|
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
|
||||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||||
) = IncomingVerificationPresenter(
|
) = IncomingVerificationPresenter(
|
||||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
verificationRequest = verificationRequest,
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
sessionVerificationService = service,
|
sessionVerificationService = service,
|
||||||
stateMachine = IncomingVerificationStateMachine(service),
|
stateMachine = IncomingVerificationStateMachine(service),
|
||||||
dateFormatter = dateFormatter,
|
dateFormatter = dateFormatter,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,27 +9,21 @@ package io.element.android.features.verifysession.impl.outgoing
|
||||||
|
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
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.test.FakeLogoutUseCase
|
|
||||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
|
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.matrix.api.core.UserId
|
||||||
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.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
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
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.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.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.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -48,85 +42,70 @@ class VerifySelfSessionPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
awaitItem().run {
|
awaitItem().run {
|
||||||
assertThat(step).isEqualTo(Step.Initial(false))
|
assertThat(step).isEqualTo(Step.Initial)
|
||||||
assertThat(displaySkipButton).isTrue()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
fun `present - Handles requestVerification for session verification`() = runTest {
|
||||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
val requestSessionVerificationRecorder = lambdaRecorder<Unit> {}
|
||||||
val presenter = createVerifySelfSessionPresenter(
|
val startVerificationRecorder = lambdaRecorder<Unit> {}
|
||||||
service = unverifiedSessionService(),
|
|
||||||
buildMeta = buildMeta,
|
|
||||||
)
|
|
||||||
presenter.test {
|
|
||||||
assertThat(awaitItem().displaySkipButton).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Initial state is received, can use recovery key`() = runTest {
|
|
||||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
|
||||||
val presenter = createVerifySelfSessionPresenter(
|
|
||||||
service = unverifiedSessionService(
|
|
||||||
resetLambda = resetLambda
|
|
||||||
),
|
|
||||||
encryptionService = FakeEncryptionService().apply {
|
|
||||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
presenter.test {
|
|
||||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
|
|
||||||
resetLambda.assertions().isCalledOnce().with(value(true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
|
|
||||||
val presenter = createVerifySelfSessionPresenter(
|
|
||||||
service = unverifiedSessionService(),
|
|
||||||
encryptionService = FakeEncryptionService().apply {
|
|
||||||
emitIsLastDevice(true)
|
|
||||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
presenter.test {
|
|
||||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Handles requestVerification`() = runTest {
|
|
||||||
val service = unverifiedSessionService(
|
val service = unverifiedSessionService(
|
||||||
requestVerificationLambda = { },
|
requestSessionVerificationLambda = requestSessionVerificationRecorder,
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = startVerificationRecorder,
|
||||||
|
)
|
||||||
|
val presenter = createVerifySelfSessionPresenter(
|
||||||
|
service = service,
|
||||||
|
verificationRequest = anOutgoingSessionVerificationRequest(),
|
||||||
)
|
)
|
||||||
val presenter = createVerifySelfSessionPresenter(service)
|
|
||||||
presenter.test {
|
presenter.test {
|
||||||
requestVerificationAndAwaitVerifyingState(service)
|
requestVerificationAndAwaitVerifyingState(service)
|
||||||
|
|
||||||
|
requestSessionVerificationRecorder.assertions().isCalledOnce()
|
||||||
|
startVerificationRecorder.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Cancellation on initial state does nothing`() = runTest {
|
fun `present - Handles requestVerification for user verification`() = runTest {
|
||||||
|
val requestUserVerificationRecorder = lambdaRecorder<UserId, Unit> {}
|
||||||
|
val startVerificationRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val service = unverifiedSessionService(
|
||||||
|
requestUserVerificationLambda = requestUserVerificationRecorder,
|
||||||
|
startVerificationLambda = startVerificationRecorder,
|
||||||
|
)
|
||||||
|
val presenter = createVerifySelfSessionPresenter(
|
||||||
|
service = service,
|
||||||
|
verificationRequest = anOutgoingUserVerificationRequest(),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
requestVerificationAndAwaitVerifyingState(service)
|
||||||
|
|
||||||
|
requestUserVerificationRecorder.assertions().isCalledOnce()
|
||||||
|
startVerificationRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Cancellation on initial state moves to Exit state`() = runTest {
|
||||||
val presenter = createVerifySelfSessionPresenter(
|
val presenter = createVerifySelfSessionPresenter(
|
||||||
service = unverifiedSessionService(),
|
service = unverifiedSessionService(),
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
assertThat(initialState.step).isEqualTo(Step.Initial)
|
||||||
val eventSink = initialState.eventSink
|
val eventSink = initialState.eventSink
|
||||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||||
expectNoEvents()
|
|
||||||
|
assertThat(awaitItem().step).isEqualTo(Step.Exit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
approveVerificationLambda = { },
|
approveVerificationLambda = { },
|
||||||
)
|
)
|
||||||
|
|
@ -143,24 +122,23 @@ class VerifySelfSessionPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 canceled one`() = runTest {
|
||||||
val service = unverifiedSessionService(
|
val service = unverifiedSessionService(
|
||||||
requestVerificationLambda = { },
|
requestSessionVerificationLambda = { },
|
||||||
)
|
)
|
||||||
val presenter = createVerifySelfSessionPresenter(service)
|
val presenter = createVerifySelfSessionPresenter(service)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
|
|
||||||
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||||
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
||||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
cancelVerificationLambda = { },
|
cancelVerificationLambda = { },
|
||||||
)
|
)
|
||||||
|
|
@ -175,7 +153,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
@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 = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
)
|
)
|
||||||
val presenter = createVerifySelfSessionPresenter(service)
|
val presenter = createVerifySelfSessionPresenter(service)
|
||||||
|
|
@ -189,7 +167,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
||||||
val service = unverifiedSessionService(
|
val service = unverifiedSessionService(
|
||||||
requestVerificationLambda = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
)
|
)
|
||||||
val presenter = createVerifySelfSessionPresenter(service)
|
val presenter = createVerifySelfSessionPresenter(service)
|
||||||
|
|
@ -199,7 +177,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
assertThat(awaitItem().step).isEqualTo(Step.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().step).isEqualTo(Step.Initial(false))
|
assertThat(awaitItem().step).isEqualTo(Step.Initial)
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +188,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||||
)
|
)
|
||||||
val service = unverifiedSessionService(
|
val service = unverifiedSessionService(
|
||||||
requestVerificationLambda = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
approveVerificationLambda = { },
|
approveVerificationLambda = { },
|
||||||
)
|
)
|
||||||
|
|
@ -235,7 +213,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
@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 = { },
|
requestSessionVerificationLambda = { },
|
||||||
startVerificationLambda = { },
|
startVerificationLambda = { },
|
||||||
declineVerificationLambda = { },
|
declineVerificationLambda = { },
|
||||||
)
|
)
|
||||||
|
|
@ -254,20 +232,6 @@ class VerifySelfSessionPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Skip event skips the flow`() = runTest {
|
|
||||||
val service = unverifiedSessionService(
|
|
||||||
requestVerificationLambda = { },
|
|
||||||
startVerificationLambda = { },
|
|
||||||
)
|
|
||||||
val presenter = createVerifySelfSessionPresenter(service)
|
|
||||||
presenter.test {
|
|
||||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
|
||||||
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
|
|
||||||
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(
|
val service = FakeSessionVerificationService(
|
||||||
|
|
@ -301,32 +265,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
assertThat(awaitItem().step).isEqualTo(Step.Exit)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
|
|
||||||
val service = FakeSessionVerificationService(
|
|
||||||
resetLambda = { },
|
|
||||||
).apply {
|
|
||||||
emitNeedsSessionVerification(false)
|
|
||||||
emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
|
||||||
emitVerificationFlowState(VerificationFlowState.DidFinish)
|
|
||||||
}
|
|
||||||
val signOutLambda = lambdaRecorder<Boolean, Unit> {}
|
|
||||||
val presenter = createVerifySelfSessionPresenter(
|
|
||||||
service,
|
|
||||||
logoutUseCase = FakeLogoutUseCase(signOutLambda)
|
|
||||||
)
|
|
||||||
presenter.test {
|
|
||||||
skipItems(1)
|
|
||||||
val initialItem = awaitItem()
|
|
||||||
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
|
|
||||||
assertThat(awaitItem().signOutAction.isLoading()).isTrue()
|
|
||||||
val finalItem = awaitItem()
|
|
||||||
assertThat(finalItem.signOutAction.isSuccess()).isTrue()
|
|
||||||
signOutLambda.assertions().isCalledOnce().with(value(true))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,10 +274,7 @@ class VerifySelfSessionPresenterTest {
|
||||||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||||
): VerifySelfSessionState {
|
): VerifySelfSessionState {
|
||||||
var state = awaitItem()
|
var state = awaitItem()
|
||||||
assertThat(state.step).isEqualTo(Step.Initial(false))
|
assertThat(state.step).isEqualTo(Step.Initial)
|
||||||
state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
|
|
||||||
state = awaitItem()
|
|
||||||
assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
|
|
||||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||||
// Await for other device response:
|
// Await for other device response:
|
||||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||||
|
|
@ -360,17 +296,19 @@ class VerifySelfSessionPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun unverifiedSessionService(
|
private suspend fun unverifiedSessionService(
|
||||||
requestVerificationLambda: () -> Unit = { lambdaError() },
|
requestSessionVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
|
requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
|
||||||
cancelVerificationLambda: () -> Unit = { lambdaError() },
|
cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
approveVerificationLambda: () -> Unit = { lambdaError() },
|
approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
declineVerificationLambda: () -> Unit = { lambdaError() },
|
declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
startVerificationLambda: () -> Unit = { lambdaError() },
|
startVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
resetLambda: (Boolean) -> Unit = { },
|
resetLambda: (Boolean) -> Unit = { },
|
||||||
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
|
||||||
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||||
): FakeSessionVerificationService {
|
): FakeSessionVerificationService {
|
||||||
return FakeSessionVerificationService(
|
return FakeSessionVerificationService(
|
||||||
requestVerificationLambda = requestVerificationLambda,
|
requestCurrentSessionVerificationLambda = requestSessionVerificationLambda,
|
||||||
|
requestUserVerificationLambda = requestUserVerificationLambda,
|
||||||
cancelVerificationLambda = cancelVerificationLambda,
|
cancelVerificationLambda = cancelVerificationLambda,
|
||||||
approveVerificationLambda = approveVerificationLambda,
|
approveVerificationLambda = approveVerificationLambda,
|
||||||
declineVerificationLambda = declineVerificationLambda,
|
declineVerificationLambda = declineVerificationLambda,
|
||||||
|
|
@ -385,20 +323,15 @@ class VerifySelfSessionPresenterTest {
|
||||||
|
|
||||||
private fun createVerifySelfSessionPresenter(
|
private fun createVerifySelfSessionPresenter(
|
||||||
service: SessionVerificationService,
|
service: SessionVerificationService,
|
||||||
|
verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
|
||||||
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
|
|
||||||
showDeviceVerifiedScreen: Boolean = false,
|
showDeviceVerifiedScreen: Boolean = false,
|
||||||
): VerifySelfSessionPresenter {
|
): VerifySelfSessionPresenter {
|
||||||
return VerifySelfSessionPresenter(
|
return VerifySelfSessionPresenter(
|
||||||
showDeviceVerifiedScreen = showDeviceVerifiedScreen,
|
showDeviceVerifiedScreen = showDeviceVerifiedScreen,
|
||||||
|
verificationRequest = verificationRequest,
|
||||||
sessionVerificationService = service,
|
sessionVerificationService = service,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
|
|
||||||
buildMeta = buildMeta,
|
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
|
||||||
logoutUseCase = logoutUseCase,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
|
||||||
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
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.ensureCalledOnce
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
|
@ -25,7 +24,6 @@ import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class VerifySelfSessionViewTest {
|
class VerifySelfSessionViewTest {
|
||||||
|
|
@ -103,16 +101,16 @@ class VerifySelfSessionViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `back key pressed - on Completed step does nothing`() {
|
fun `back key pressed - on Completed exits the flow`() {
|
||||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
ensureCalledOnce { callback ->
|
||||||
rule.setVerifySelfSessionView(
|
rule.setVerifySelfSessionView(
|
||||||
aVerifySelfSessionState(
|
onBack = callback,
|
||||||
step = VerifySelfSessionState.Step.Completed,
|
state = aVerifySelfSessionState(
|
||||||
eventSink = eventsRecorder
|
step = VerifySelfSessionState.Step.Completed,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.pressBackKey()
|
rule.pressBackKey()
|
||||||
eventsRecorder.assertEmpty()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -130,38 +128,6 @@ class VerifySelfSessionViewTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config(qualifiers = "h1024dp")
|
|
||||||
@Test
|
|
||||||
fun `clicking on enter recovery key calls the expected callback`() {
|
|
||||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
|
|
||||||
ensureCalledOnce { callback ->
|
|
||||||
rule.setVerifySelfSessionView(
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = VerifySelfSessionState.Step.Initial(true),
|
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
onEnterRecoveryKey = callback,
|
|
||||||
)
|
|
||||||
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Config(qualifiers = "h1024dp")
|
|
||||||
@Test
|
|
||||||
fun `clicking on learn more invokes the expected callback`() {
|
|
||||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
|
|
||||||
ensureCalledOnce { callback ->
|
|
||||||
rule.setVerifySelfSessionView(
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = VerifySelfSessionState.Step.Initial(true),
|
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
onLearnMoreClick = callback,
|
|
||||||
)
|
|
||||||
rule.clickOn(CommonStrings.action_learn_more)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on they match emits the expected event`() {
|
fun `clicking on they match emits the expected event`() {
|
||||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||||
|
|
@ -194,48 +160,18 @@ class VerifySelfSessionViewTest {
|
||||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
|
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `clicking on 'Skip' emits the expected event`() {
|
|
||||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
|
||||||
rule.setVerifySelfSessionView(
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
|
|
||||||
displaySkipButton = true,
|
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
)
|
|
||||||
rule.clickOn(CommonStrings.action_skip)
|
|
||||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.SkipVerification)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `on Skipped step - onFinished callback is called immediately`() {
|
|
||||||
ensureCalledOnce { callback ->
|
|
||||||
rule.setVerifySelfSessionView(
|
|
||||||
aVerifySelfSessionState(
|
|
||||||
step = VerifySelfSessionState.Step.Skipped,
|
|
||||||
displaySkipButton = true,
|
|
||||||
eventSink = EnsureNeverCalledWithParam(),
|
|
||||||
),
|
|
||||||
onFinished = callback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
|
||||||
state: VerifySelfSessionState,
|
state: VerifySelfSessionState,
|
||||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
|
||||||
onFinished: () -> Unit = EnsureNeverCalled(),
|
onFinished: () -> Unit = EnsureNeverCalled(),
|
||||||
onResetKey: () -> Unit = EnsureNeverCalled(),
|
onBack: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
VerifySelfSessionView(
|
VerifySelfSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
onLearnMoreClick = onLearnMoreClick,
|
onLearnMoreClick = onLearnMoreClick,
|
||||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
|
||||||
onFinish = onFinished,
|
onFinish = onFinished,
|
||||||
onResetKey = onResetKey,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ android {
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.vanniktech.blurhash)
|
implementation(libs.vanniktech.blurhash)
|
||||||
implementation(projects.features.enterprise.api)
|
implementation(projects.features.enterprise.api)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ 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.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
@ -78,6 +79,11 @@ object BigIcon {
|
||||||
* A success style with a tinted background.
|
* A success style with a tinted background.
|
||||||
*/
|
*/
|
||||||
data object SuccessSolid : Style
|
data object SuccessSolid : Style
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loading style with the default background color.
|
||||||
|
*/
|
||||||
|
data object Loading : Style
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,31 +107,7 @@ object BigIcon {
|
||||||
Style.Success -> Color.Transparent
|
Style.Success -> Color.Transparent
|
||||||
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
|
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
|
||||||
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
|
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
|
||||||
}
|
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
|
||||||
val icon = when (style) {
|
|
||||||
is Style.Default -> style.vectorIcon
|
|
||||||
Style.Alert,
|
|
||||||
Style.AlertSolid -> CompoundIcons.ErrorSolid()
|
|
||||||
Style.Success,
|
|
||||||
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
|
|
||||||
}
|
|
||||||
val contentDescription = when (style) {
|
|
||||||
is Style.Default -> style.contentDescription
|
|
||||||
Style.Alert,
|
|
||||||
Style.AlertSolid -> stringResource(CommonStrings.common_error)
|
|
||||||
Style.Success,
|
|
||||||
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
|
|
||||||
}
|
|
||||||
val iconTint = when (style) {
|
|
||||||
is Style.Default -> if (style.useCriticalTint) {
|
|
||||||
ElementTheme.colors.iconCriticalPrimary
|
|
||||||
} else {
|
|
||||||
ElementTheme.colors.iconSecondary
|
|
||||||
}
|
|
||||||
Style.Alert,
|
|
||||||
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
|
|
||||||
Style.Success,
|
|
||||||
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
|
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -134,12 +116,50 @@ object BigIcon {
|
||||||
.background(backgroundColor),
|
.background(backgroundColor),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (style is Style.Loading) {
|
||||||
modifier = Modifier.size(32.dp),
|
CircularProgressIndicator(
|
||||||
tint = iconTint,
|
modifier = Modifier.size(27.dp),
|
||||||
imageVector = icon,
|
color = ElementTheme.colors.iconSecondary,
|
||||||
contentDescription = contentDescription
|
trackColor = Color.Transparent,
|
||||||
)
|
strokeWidth = 3.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val icon = when (style) {
|
||||||
|
is Style.Default -> style.vectorIcon
|
||||||
|
Style.Alert,
|
||||||
|
Style.AlertSolid -> CompoundIcons.ErrorSolid()
|
||||||
|
Style.Success,
|
||||||
|
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
|
||||||
|
Style.Loading -> error("This should never be reached")
|
||||||
|
}
|
||||||
|
val contentDescription = when (style) {
|
||||||
|
is Style.Default -> style.contentDescription
|
||||||
|
Style.Alert,
|
||||||
|
Style.AlertSolid -> stringResource(CommonStrings.common_error)
|
||||||
|
Style.Success,
|
||||||
|
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
|
||||||
|
Style.Loading -> error("This should never be reached")
|
||||||
|
}
|
||||||
|
val iconTint = when (style) {
|
||||||
|
is Style.Default -> if (style.useCriticalTint) {
|
||||||
|
ElementTheme.colors.iconCriticalPrimary
|
||||||
|
} else {
|
||||||
|
ElementTheme.colors.iconSecondary
|
||||||
|
}
|
||||||
|
Style.Alert,
|
||||||
|
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
|
||||||
|
Style.Success,
|
||||||
|
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
|
||||||
|
Style.Loading -> error("This should never be reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = iconTint,
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +193,7 @@ internal class BigIconStyleProvider : PreviewParameterProvider<BigIcon.Style> {
|
||||||
BigIcon.Style.AlertSolid,
|
BigIcon.Style.AlertSolid,
|
||||||
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
|
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
|
||||||
BigIcon.Style.Success,
|
BigIcon.Style.Success,
|
||||||
BigIcon.Style.SuccessSolid
|
BigIcon.Style.SuccessSolid,
|
||||||
|
BigIcon.Style.Loading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,6 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
MediaSender(32.dp),
|
MediaSender(32.dp),
|
||||||
|
|
||||||
DmCreationConfirmation(64.dp),
|
DmCreationConfirmation(64.dp),
|
||||||
|
|
||||||
|
UserVerification(52.dp),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.utils
|
||||||
|
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||||
|
|
||||||
|
@Suppress("MutableStateParam")
|
||||||
|
@Composable
|
||||||
|
fun OpenUrlInTabView(url: MutableState<String?>) {
|
||||||
|
val activity = requireNotNull(LocalActivity.current)
|
||||||
|
val darkTheme = ElementTheme.isLightTheme.not()
|
||||||
|
|
||||||
|
LaunchedEffect(url.value) {
|
||||||
|
url.value?.let {
|
||||||
|
activity.openUrlInChromeCustomTab(null, darkTheme, it)
|
||||||
|
url.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,15 @@ import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class SessionVerificationRequestDetails(
|
data class SessionVerificationRequestDetails(
|
||||||
val senderId: UserId,
|
val senderProfile: SenderProfile,
|
||||||
val flowId: FlowId,
|
val flowId: FlowId,
|
||||||
val deviceId: DeviceId,
|
val deviceId: DeviceId,
|
||||||
val displayName: String?,
|
|
||||||
val firstSeenTimestamp: Long,
|
val firstSeenTimestamp: Long,
|
||||||
) : Parcelable
|
) : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
data class SenderProfile(
|
||||||
|
val userId: UserId,
|
||||||
|
val displayName: String?,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
) : Parcelable
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.matrix.api.verification
|
package io.element.android.libraries.matrix.api.verification
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
|
@ -31,7 +32,12 @@ interface SessionVerificationService {
|
||||||
/**
|
/**
|
||||||
* Request verification of the current session.
|
* Request verification of the current session.
|
||||||
*/
|
*/
|
||||||
suspend fun requestVerification()
|
suspend fun requestCurrentSessionVerification()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request verification of the user with the given [userId].
|
||||||
|
*/
|
||||||
|
suspend fun requestUserVerification(userId: UserId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels the current verification attempt.
|
* Cancels the current verification attempt.
|
||||||
|
|
@ -67,7 +73,7 @@ interface SessionVerificationService {
|
||||||
* Set this particular request as the currently active one and register for
|
* Set this particular request as the currently active one and register for
|
||||||
* events pertaining it.
|
* events pertaining it.
|
||||||
*/
|
*/
|
||||||
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
|
suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the previously acknowledged verification request.
|
* Accept the previously acknowledged verification request.
|
||||||
|
|
@ -76,7 +82,7 @@ interface SessionVerificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionVerificationServiceListener {
|
interface SessionVerificationServiceListener {
|
||||||
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
|
fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verification status of the current session. */
|
/** Verification status of the current session. */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.verification
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
sealed interface VerificationRequest : Parcelable {
|
||||||
|
sealed interface Outgoing : VerificationRequest {
|
||||||
|
@Parcelize
|
||||||
|
data object CurrentSession : Outgoing
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class User(val userId: UserId) : Outgoing
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Incoming(open val details: SessionVerificationRequestDetails) : VerificationRequest {
|
||||||
|
@Parcelize
|
||||||
|
data class OtherSession(override val details: SessionVerificationRequestDetails) : Incoming(details)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class User(override val details: SessionVerificationRequestDetails) : Incoming(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,14 @@
|
||||||
package io.element.android.libraries.matrix.impl.verification
|
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.core.UserId
|
||||||
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.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.api.verification.VerificationRequest
|
||||||
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.NonCancellable
|
||||||
|
|
@ -50,6 +51,8 @@ class RustSessionVerificationService(
|
||||||
isSyncServiceReady: Flow<Boolean>,
|
isSyncServiceReady: Flow<Boolean>,
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
||||||
|
private var currentVerificationRequest: VerificationRequest? = null
|
||||||
|
|
||||||
private val encryptionService: Encryption = client.encryption()
|
private val encryptionService: Encryption = client.encryption()
|
||||||
private lateinit var verificationController: SessionVerificationController
|
private lateinit var verificationController: SessionVerificationController
|
||||||
|
|
||||||
|
|
@ -88,10 +91,8 @@ class RustSessionVerificationService(
|
||||||
verificationStatus == SessionVerifiedStatus.NotVerified
|
verificationStatus == SessionVerifiedStatus.NotVerified
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isOwnVerification = true
|
|
||||||
|
|
||||||
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
||||||
listener?.onIncomingSessionRequest(details.map())
|
listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId())))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var listener: SessionVerificationServiceListener? = null
|
private var listener: SessionVerificationServiceListener? = null
|
||||||
|
|
@ -111,9 +112,16 @@ class RustSessionVerificationService(
|
||||||
this.listener = listener
|
this.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun requestVerification() = tryOrFail {
|
override suspend fun requestCurrentSessionVerification() = tryOrFail {
|
||||||
initVerificationControllerIfNeeded()
|
initVerificationControllerIfNeeded()
|
||||||
verificationController.requestDeviceVerification()
|
verificationController.requestDeviceVerification()
|
||||||
|
currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun requestUserVerification(userId: UserId) = tryOrFail {
|
||||||
|
initVerificationControllerIfNeeded()
|
||||||
|
verificationController.requestUserVerification(userId.value)
|
||||||
|
currentVerificationRequest = VerificationRequest.Outgoing.User(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cancelVerification() = tryOrFail {
|
override suspend fun cancelVerification() = tryOrFail {
|
||||||
|
|
@ -130,16 +138,16 @@ class RustSessionVerificationService(
|
||||||
verificationController.startSasVerification()
|
verificationController.startSasVerification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
|
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail {
|
||||||
isOwnVerification = false
|
|
||||||
initVerificationControllerIfNeeded()
|
initVerificationControllerIfNeeded()
|
||||||
verificationController.acknowledgeVerificationRequest(
|
verificationController.acknowledgeVerificationRequest(
|
||||||
senderId = details.senderId.value,
|
senderId = verificationRequest.details.senderProfile.userId.value,
|
||||||
flowId = details.flowId.value,
|
flowId = verificationRequest.details.flowId.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acceptVerificationRequest() = tryOrFail {
|
override suspend fun acceptVerificationRequest() = tryOrFail {
|
||||||
|
Timber.d("Accepting incoming verification request")
|
||||||
verificationController.acceptVerificationRequest()
|
verificationController.acceptVerificationRequest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +191,7 @@ class RustSessionVerificationService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
if (isOwnVerification) {
|
if (currentVerificationRequest is VerificationRequest.Outgoing.CurrentSession) {
|
||||||
// Try waiting for the final recovery state for better UX, but don't block the verification state on it
|
// Try waiting for the final recovery state for better UX, but don't block the verification state on it
|
||||||
tryOrNull {
|
tryOrNull {
|
||||||
withTimeout(10.seconds) {
|
withTimeout(10.seconds) {
|
||||||
|
|
@ -215,7 +223,7 @@ class RustSessionVerificationService(
|
||||||
// end-region
|
// end-region
|
||||||
|
|
||||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||||
isOwnVerification = true
|
currentVerificationRequest = null
|
||||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||||
// Cancel any pending verification attempt
|
// Cancel any pending verification attempt
|
||||||
tryOrNull { verificationController.cancelVerification() }
|
tryOrNull { verificationController.cancelVerification() }
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,28 @@ 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.FlowId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||||
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||||
|
import org.matrix.rustcomponents.sdk.UserProfile as RustUserProfile
|
||||||
|
|
||||||
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
||||||
senderId = UserId(senderProfile.userId),
|
senderProfile = senderProfile.map(),
|
||||||
flowId = FlowId(flowId),
|
flowId = FlowId(flowId),
|
||||||
deviceId = DeviceId(deviceId),
|
deviceId = DeviceId(deviceId),
|
||||||
displayName = senderProfile.displayName,
|
|
||||||
firstSeenTimestamp = firstSeenTimestamp.toLong(),
|
firstSeenTimestamp = firstSeenTimestamp.toLong(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun RustUserProfile.map() = SessionVerificationRequestDetails.SenderProfile(
|
||||||
|
userId = UserId(userId),
|
||||||
|
displayName = displayName,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming {
|
||||||
|
val details = map()
|
||||||
|
return if (currentUserId == details.senderProfile.userId) {
|
||||||
|
VerificationRequest.Incoming.OtherSession(details)
|
||||||
|
} else {
|
||||||
|
VerificationRequest.Incoming.User(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.test.verification
|
package io.element.android.libraries.matrix.test.verification
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
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.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.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -20,13 +21,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class FakeSessionVerificationService(
|
class FakeSessionVerificationService(
|
||||||
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
|
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
|
||||||
private val requestVerificationLambda: () -> Unit = { lambdaError() },
|
private val requestCurrentSessionVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
|
private val requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
|
||||||
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
|
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
private val approveVerificationLambda: () -> Unit = { lambdaError() },
|
private val approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
private val declineVerificationLambda: () -> Unit = { lambdaError() },
|
private val declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
private val startVerificationLambda: () -> Unit = { lambdaError() },
|
private val startVerificationLambda: () -> Unit = { lambdaError() },
|
||||||
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
|
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
|
||||||
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
|
||||||
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||||
) : SessionVerificationService {
|
) : SessionVerificationService {
|
||||||
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
|
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
|
||||||
|
|
@ -37,8 +39,12 @@ class FakeSessionVerificationService(
|
||||||
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 requestCurrentSessionVerification() {
|
||||||
requestVerificationLambda()
|
requestCurrentSessionVerificationLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun requestUserVerification(userId: UserId) {
|
||||||
|
requestUserVerificationLambda(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cancelVerification() {
|
override suspend fun cancelVerification() {
|
||||||
|
|
@ -68,8 +74,8 @@ class FakeSessionVerificationService(
|
||||||
this.listener = listener
|
this.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
|
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||||
acknowledgeVerificationRequestLambda(details)
|
acknowledgeVerificationRequestLambda(verificationRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun acceptVerificationRequest() = simulateLongTask {
|
override suspend fun acceptVerificationRequest() = simulateLongTask {
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,8 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.ui.strings"
|
namespace = "io.element.android.libraries.ui.strings"
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += "Typos"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,9 @@ class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
|
||||||
lambdaError("Should not be called and is called with $p1 and $p2")
|
lambdaError("Should not be called and is called with $p1 and $p2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EnsureNeverCalledWithThreeParams<T, U, V> : (T, U, V) -> Unit {
|
||||||
|
override fun invoke(p1: T, p2: U, p3: V) {
|
||||||
|
lambdaError("Should not be called and is called with $p1, $p2 and $p3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:470cb7c851af9d91cc44d52d502b50764136b079b600ca58ae2512e7ff12e34b
|
||||||
|
size 29534
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49db4684bd8f16361848130650ce1ee0fbc49fff5452fab177e5920ab857c1a9
|
||||||
|
size 24663
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d1504a5137b90bf6c03ae8d3a90946ae1a9b528013f9c3898a0e91e4d073d159
|
||||||
|
size 34347
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bd973e0f9c5940e3f216f27af682fffd156535b6d759e4cec01c3979eb35f1c9
|
||||||
|
size 29624
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9dba1ef09211361216ee035c64ee50d62ab1df5191d365ef26f5a4f741477867
|
||||||
|
size 28635
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ba7502251d19d1fe54fa631b7c71afcdfc3c864cbab04d741a0c4a8d691b2e7f
|
||||||
|
size 24048
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:14770fdc3e68b250b5f7dad6bb6fb1bf387c66774287276ab4b505c144007b33
|
||||||
|
size 33299
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0466b1f78eda1575072f9be443fcb5741ff9952fd215b9aa9b2281de7109da70
|
||||||
|
size 28783
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7479bd11493b82e7027599a938979fd50fb75c31cf7c505cf8669db9387fb124
|
oid sha256:f1a69af9c184578a355345037ec34016c4b4ab26a2719bfaffafa1027365a678
|
||||||
size 38805
|
size 38453
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56418af183e3287631cead30c1077d5734ff59f014c03e2ea0df8d91d38ade01
|
oid sha256:c41fdaa2341788494026277683590e0fe58fcc6b4c93919c77dcf29fd1e95c15
|
||||||
size 42255
|
size 41752
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56c21416493a55bb9f105a9908f2b9e07441ec5f5ae3afb68f59fb8329cdb242
|
oid sha256:e99e06f6281be006fb12e0a56e7523c8144256562464474cb2aa918f6576d636
|
||||||
size 39527
|
size 39091
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:128bc1d07c659dfdb25a5cac2513ef3dfcad6a156fd3a38457eec40a735e74c9
|
oid sha256:d59deaee5d608c279670104c0f3bfcb5bbda90ac9779111441053ea1b9d40d4c
|
||||||
size 43265
|
size 42658
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f41090fceca5845f88dbbc6b983e6330ecd3b86d2056ba4584f1fca26305bb0a
|
oid sha256:4e395d9ca4fc6b2eb1ea9a8fd6e2ad34e7397d9f64cf39e05eed50a1bfffabd7
|
||||||
size 28943
|
size 24186
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0516c0355081b7b2d745ec46fd160fd566be224ef7d350849c9990a8c6320563
|
oid sha256:87cb4255c8346796a226cdfc2afb67641a3b27c2c1a15629a07c3293fcad5131
|
||||||
size 27018
|
size 22078
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f20c32a85bf93f25c51c18b79adc0f8237173dc9fe0705570634adbcc94b1350
|
oid sha256:ba80787576dd893f90fcdc83021e47abbde4ae2bcaaca2c167178ba7c2be3886
|
||||||
size 36484
|
size 36481
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5cc7bdca7fe74fe0b16ab23eb4866b3af5245ed762c74a1edba087070cfe32b8
|
oid sha256:3a2278fd516dff3a5c86702f09d5621e8b3bb3de1df702b5036fa114975e4d85
|
||||||
size 30448
|
size 27844
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dfc7c5d99c0d1e5e0194a5961aac43efea94ae100fca0249f6b00e20b47cbb09
|
oid sha256:a465e6254ae595d62a335914414ad2f33b76736f529d1e188f3d0e0d57268f65
|
||||||
size 26565
|
size 23015
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e2f9d02116554413601c8b11fb75581f30ebb6ce5b8ade2a5a62a0aa4ce04705
|
oid sha256:ef88b209eb93fb6b4e5aa411c4cdcb9ff1aa49bd44f11c2271a4d758482b145a
|
||||||
size 29914
|
size 25181
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f86dd19d8056f16ae1a5337c49f35d420f2b83baafb29360c8c07c3347d5a93f
|
oid sha256:6894ddb5662e7da82e5ee42fb0c9eb30afd87e15e415ba42be218ebab434d53a
|
||||||
size 28074
|
size 23531
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:46d3c77fff43081041fb952fe9c8ea733c5ec164ca9aca548924d3cbefb52369
|
oid sha256:0f393f620cc11b7aec7bf52f7efc5a5872ed07a14e7fbf2149b16171dbb2b03e
|
||||||
size 26131
|
size 21463
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7fdbd16b4e78f890f60668fccfbe7af6c6a4f5ba22af26e9f3735dafb13e9d8f
|
oid sha256:ce136a49edadeaadc229ec485b27bf2d4ae20601e948bca46c632a4035b90130
|
||||||
size 33700
|
size 33697
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:79f2c2bb7772dd91b37351e4d1e658155f341d83cb6d496eded65b0b543fa2b1
|
oid sha256:580fc1fe77975e83cf83fe582c2f3f4aa7003cf302e7dadcd6bb97c04cbbf148
|
||||||
size 27898
|
size 25517
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4acb962472308c94998b3bc67d71a6a69b9bad0294461df374f7e74dcf703328
|
oid sha256:4b48c3e2c5e06a612473e252ba52eead6efd741b7ea21fdb778ec9ed5952a2a2
|
||||||
size 24379
|
size 21181
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a409716052c9c12c963668098943b8f286e41d6a10f5639d11b7b464cb9d0c3d
|
oid sha256:201c3f2385059321f7500ca8d559bb973a51aeb56fd417e943be496ae55d0869
|
||||||
size 28909
|
size 24370
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b8634747fdeca8b754396dd32e02c36956017860118dbc33098aaa10fee358ec
|
||||||
|
size 28876
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:66af8c7679f62fede7b09d6661f0b78d7264a2a415cdf5faf0928aa115adfc2b
|
||||||
|
size 24514
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7c89798843bd3bc2a02094e200ddfc4fcdad8e71554d64c3f0cf2bfc75e894dc
|
||||||
|
size 24514
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7c89798843bd3bc2a02094e200ddfc4fcdad8e71554d64c3f0cf2bfc75e894dc
|
||||||
|
size 24514
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2473606524b856b4afd81e4a32b29c7456788746078387fff280f3c8eb577396
|
oid sha256:1da0edef645c5abc391f0425e3a1f8719c1f061f66517b131a81cdd59d2bd04a
|
||||||
size 41622
|
size 40105
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a8c359687b3ec5f038602b9022ef3967af12c6632a59c56bd10fecf7d92233c
|
oid sha256:8ebb02ae3454f3cbb28e3aac886d088888251d25ba2c5e8fb7adde700f10383e
|
||||||
size 46457
|
size 36323
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b6576c0b77984df1930f0115e1ea90e6720f0fb5f500e186c591f0fd2eb771b6
|
oid sha256:682472baf2a1bc5f99cfadc3177615167f1f7531923ec34374f59e263b50d79f
|
||||||
size 44729
|
size 37049
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:90c8e131e639b6638a75468d41a3ff0107822e3450c6edbcc4d9eccdd180f6ae
|
oid sha256:f8aa70b0d5eace900fc8c58674d2f3cdcf409335a33e8cf3eea5f60fb495d009
|
||||||
size 31537
|
size 32299
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3fc2ecdf5b8880b67e9ee9eacba9636819396236325f6ce28f0bf5755fa5ae5e
|
oid sha256:c28294b67c1d8f448300b35a4ec6ebecd105334375af61b61612b82b5509d417
|
||||||
size 21892
|
size 46402
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b
|
oid sha256:becc11517522d030f4539c67d7b4728661135a43b05e9c902481b79539155912
|
||||||
size 24134
|
size 46738
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b
|
oid sha256:baa21d8011741ac2bca82ac26fa289d016a3ddcc16d408f92812adc662e27869
|
||||||
size 24134
|
size 40088
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c93ea8f1be90fc1bc43bc94c86eb7b7605872fbd96ac142ba1d633e20978d7a1
|
||||||
|
size 40427
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:acd3515c98dc74f265fbd4c8b82c60a65ae7c764994016ab39bf25062d70140f
|
||||||
|
size 31496
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:227c4049433b734d591eeea0cb5cca65ce27b3f4fe385eb224ec322148865f6b
|
||||||
|
size 28321
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a0393e0549647a0fe6ebef2372d9e6885db7225d137e44d4057e3fe0a15b1aa8
|
||||||
|
size 23895
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb51fe5a51969f768609a5e5eaf5baa95005b094bf1995c3b304f637bf58fbc7
|
||||||
|
size 24251
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb51fe5a51969f768609a5e5eaf5baa95005b094bf1995c3b304f637bf58fbc7
|
||||||
|
size 24251
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue