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
2ce1b17dae
commit
f73c0e42a4
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.toRoomIdOrAlias
|
||||
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.VerificationRequest
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
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.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -84,6 +87,7 @@ import kotlinx.parcelize.Parcelize
|
|||
import timber.log.Timber
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
|
@ -127,8 +131,18 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
)
|
||||
|
||||
private val verificationListener = object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
||||
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||
// 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
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
||||
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupAnvil
|
||||
import org.gradle.kotlin.dsl.test
|
||||
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
|
|
@ -14,6 +15,12 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.features.ftue.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
|
|
@ -30,6 +37,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.features.securebackup.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
|
@ -37,12 +45,16 @@ dependencies {
|
|||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.permissions.noop)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(projects.appconfig)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
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.services.analytics.test)
|
||||
testImplementation(projects.services.analytics.noop)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
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.plugin.Plugin
|
||||
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.SessionScope
|
||||
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.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -59,7 +56,6 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
backstack = BackStack(
|
||||
initialElement = NavTarget.Placeholder,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
backPressHandler = NoOpBackstackHandlerStrategy(),
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
|
|
@ -104,7 +100,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.Placeholder -> {
|
||||
createNode<PlaceholderNode>(buildContext)
|
||||
}
|
||||
NavTarget.SessionVerification -> {
|
||||
is NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.navmodel.backstack.BackStack
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
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.verifysession.api.VerifySessionEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
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.matrix.api.verification.VerificationRequest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
|
@ -37,7 +44,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root(showDeviceVerifiedScreen = false),
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
|
|
@ -45,7 +52,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
) {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget
|
||||
data object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object UseAnotherDevice : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
|
@ -62,7 +72,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
override fun onDone() {
|
||||
lifecycleScope.launch {
|
||||
// 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 {
|
||||
return when (navTarget) {
|
||||
is NavTarget.Root -> {
|
||||
verifySessionEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen))
|
||||
.callback(object : VerifySessionEntryPoint.Callback {
|
||||
override fun onEnterRecoveryKey() {
|
||||
backstack.push(NavTarget.EnterRecoveryKey)
|
||||
}
|
||||
val callback = object : ChooseSelfVerificationModeNode.Callback {
|
||||
override fun onUseAnotherDevice() {
|
||||
backstack.push(NavTarget.UseAnotherDevice)
|
||||
}
|
||||
|
||||
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() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
override fun onResetKey() {
|
||||
backstack.push(NavTarget.ResetIdentity)
|
||||
override fun onBack() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onLearnMoreAboutEncryption() {
|
||||
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
|
@ -106,8 +140,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val learnMoreUrl = mutableStateOf<String?>(null)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
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"?>
|
||||
<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_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_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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.logout.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ android {
|
|||
setupAnvil()
|
||||
|
||||
dependencies {
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
@ -51,6 +52,7 @@ dependencies {
|
|||
implementation(projects.features.messages.api)
|
||||
implementation(projects.features.roomcall.api)
|
||||
implementation(projects.features.knockrequests.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
|
|
@ -21,6 +22,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
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.ElementCallEntryPoint
|
||||
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.securityandprivacy.SecurityAndPrivacyFlowNode
|
||||
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.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
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.matrix.api.core.EventId
|
||||
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.permalink.PermalinkData
|
||||
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.MediaViewerEntryPoint
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
@ -65,6 +70,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
|
||||
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
|
|
@ -118,6 +124,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object SecurityAndPrivacy : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class VerifyUser(val userId: UserId) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -168,6 +177,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.SecurityAndPrivacy)
|
||||
}
|
||||
|
||||
override fun openDmUserProfile(userId: UserId) {
|
||||
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||
}
|
||||
|
||||
override fun onJoinCall() {
|
||||
val inputs = CallType.RoomCall(
|
||||
sessionId = room.sessionId,
|
||||
|
|
@ -224,6 +237,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
override fun onStartCall(dmRoomId: RoomId) {
|
||||
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)
|
||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||
|
|
@ -301,11 +318,37 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
NavTarget.SecurityAndPrivacy -> {
|
||||
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
|
||||
override fun View(modifier: 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.libraries.androidutils.system.startSharePlainTextIntent
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -50,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
fun openPinnedMessagesList()
|
||||
fun openKnockRequestsList()
|
||||
fun openSecurityAndPrivacy()
|
||||
fun openDmUserProfile(userId: UserId)
|
||||
fun onJoinCall()
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +128,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
callbacks.forEach { it.openSecurityAndPrivacy() }
|
||||
}
|
||||
|
||||
private fun onProfileClick(userId: UserId) {
|
||||
callbacks.forEach { it.openDmUserProfile(userId) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -158,7 +164,8 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
onJoinCallClick = ::onJoinCall,
|
||||
onPinnedMessagesClick = ::openPinnedMessages,
|
||||
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.matrix.api.core.RoomAlias
|
||||
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.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
|
|
@ -101,6 +102,7 @@ fun RoomDetailsView(
|
|||
onPinnedMessagesClick: () -> Unit,
|
||||
onKnockRequestsClick: () -> Unit,
|
||||
onSecurityAndPrivacyClick: () -> Unit,
|
||||
onProfileClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -179,11 +181,16 @@ fun RoomDetailsView(
|
|||
state.eventSink(RoomDetailsEvent.SetFavorite(it))
|
||||
}
|
||||
)
|
||||
|
||||
if (state.canShowSecurityAndPrivacy) {
|
||||
SecurityAndPrivacyItem(
|
||||
onClick = onSecurityAndPrivacyClick
|
||||
)
|
||||
}
|
||||
|
||||
state.roomMemberDetailsState?.let { dmMemberDetails ->
|
||||
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private fun MembersItem(
|
||||
memberCount: Long,
|
||||
|
|
@ -655,5 +673,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
|||
onPinnedMessagesClick = {},
|
||||
onKnockRequestsClick = {},
|
||||
onSecurityAndPrivacyClick = {},
|
||||
onProfileClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
|||
onOpenDm = ::onStartDM,
|
||||
onStartCall = ::onStartCall,
|
||||
openAvatarPreview = callback::openAvatarPreview,
|
||||
onVerifyClick = callback::onVerifyUser,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import androidx.compose.ui.test.onNodeWithTag
|
|||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.test.A_USER_ID
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
|
||||
|
|
@ -294,6 +297,21 @@ class RoomDetailsViewTest {
|
|||
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(
|
||||
|
|
@ -314,6 +332,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
|
||||
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
|
||||
onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsView(
|
||||
|
|
@ -332,6 +351,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
onPinnedMessagesClick = onPinnedMessagesClick,
|
||||
onKnockRequestsClick = onKnockRequestsClick,
|
||||
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
|
||||
onProfileClick = onProfileClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ dependencies {
|
|||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
api(projects.features.userprofile.api)
|
||||
api(projects.features.userprofile.shared)
|
||||
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.impl.root.UserProfileNode
|
||||
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.BaseFlowNode
|
||||
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.matrix.api.core.EventId
|
||||
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.verification.VerificationRequest
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
|
@ -43,6 +46,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
|
|
@ -57,6 +61,9 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
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 {
|
||||
|
|
@ -74,6 +81,10 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
override fun onStartCall(dmRoomId: RoomId) {
|
||||
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)
|
||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||
|
|
@ -96,6 +107,15 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
.callback(callback)
|
||||
.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,
|
||||
onStartCall = callback::onStartCall,
|
||||
openAvatarPreview = callback::openAvatarPreview,
|
||||
onVerifyClick = callback::onVerifyUser,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
val isCurrentUser = remember { client.isMe(userId) }
|
||||
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
||||
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val isBlocked: 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) }
|
||||
.launchIn(this)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
userProfile = client.getProfile(userId).getOrNull()
|
||||
}
|
||||
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
suspend {
|
||||
client.encryptionService().isUserVerified(userId).getOrThrow()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class UserProfileNodeHelper(
|
|||
fun openAvatarPreview(username: String, avatarUrl: String)
|
||||
fun onStartDM(roomId: RoomId)
|
||||
fun onStartCall(dmRoomId: RoomId)
|
||||
fun onVerifyUser(userId: UserId)
|
||||
}
|
||||
|
||||
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.TopAppBar
|
||||
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.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ fun UserProfileView(
|
|||
onStartCall: (RoomId) -> Unit,
|
||||
goBack: () -> Unit,
|
||||
openAvatarPreview: (username: String, url: String) -> Unit,
|
||||
onVerifyClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -82,7 +84,7 @@ fun UserProfileView(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(26.dp))
|
||||
if (!state.isCurrentUser) {
|
||||
VerifyUserSection(state)
|
||||
VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) })
|
||||
BlockUserSection(state)
|
||||
BlockUserDialogs(state)
|
||||
}
|
||||
|
|
@ -116,14 +118,15 @@ fun UserProfileView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifyUserSection(state: UserProfileState) {
|
||||
private fun VerifyUserSection(
|
||||
state: UserProfileState,
|
||||
onVerifyClick: () -> Unit,
|
||||
) {
|
||||
if (state.isVerified.dataOrNull() == false) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) },
|
||||
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
|
||||
enabled = false,
|
||||
onClick = { },
|
||||
onClick = onVerifyClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +142,7 @@ internal fun UserProfileViewPreview(
|
|||
goBack = {},
|
||||
onOpenDm = {},
|
||||
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.libraries.architecture.AsyncData
|
||||
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.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.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -193,6 +195,17 @@ class UserProfileViewTest {
|
|||
rule.clickOn(CommonStrings.action_cancel)
|
||||
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(
|
||||
|
|
@ -202,6 +215,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
|
|||
onShareUser: () -> Unit = EnsureNeverCalled(),
|
||||
onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onVerifyClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
goBack: () -> Unit = EnsureNeverCalled(),
|
||||
openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
) {
|
||||
|
|
@ -213,6 +227,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setUserP
|
|||
onStartCall = onStartCall,
|
||||
goBack = goBack,
|
||||
openAvatarPreview = openAvatarPreview,
|
||||
onVerifyClick = onVerifyClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
|
||||
interface IncomingVerificationEntryPoint : FeatureEntryPoint {
|
||||
data class Params(
|
||||
val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
val verificationRequest: VerificationRequest.Incoming,
|
||||
) : NodeInputs
|
||||
|
||||
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 io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -25,8 +29,8 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
|
|||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onEnterRecoveryKey()
|
||||
fun onResetKey()
|
||||
fun onLearnMoreAboutEncryption()
|
||||
fun onBack()
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class IncomingVerificationNode @AssistedInject constructor(
|
|||
) : Node(buildContext, plugins = plugins),
|
||||
IncomingVerificationNavigator {
|
||||
private val presenter = presenterFactory.create(
|
||||
sessionVerificationRequestDetails = inputs<IncomingVerificationEntryPoint.Params>().sessionVerificationRequestDetails,
|
||||
verificationRequest = inputs<IncomingVerificationEntryPoint.Params>().verificationRequest,
|
||||
navigator = this,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import dagger.assisted.Assisted
|
||||
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.dateformatter.api.DateFormatter
|
||||
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.SessionVerificationService
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
|
||||
|
||||
class IncomingVerificationPresenter @AssistedInject constructor(
|
||||
@Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
@Assisted private val verificationRequest: VerificationRequest.Incoming,
|
||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val stateMachine: IncomingVerificationStateMachine,
|
||||
private val dateFormatter: DateFormatter,
|
||||
|
|
@ -43,49 +50,63 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
verificationRequest: VerificationRequest.Incoming,
|
||||
navigator: IncomingVerificationNavigator,
|
||||
): IncomingVerificationPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): IncomingVerificationState {
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset(
|
||||
cancelAnyPendingVerificationAttempt = false
|
||||
)
|
||||
// Acknowledge the request right now
|
||||
sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
|
||||
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 {
|
||||
dateFormatter.format(
|
||||
timestamp = sessionVerificationRequestDetails.firstSeenTimestamp,
|
||||
timestamp = verificationRequest.details.firstSeenTimestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
)
|
||||
}
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
||||
sessionVerificationRequestDetails = verificationRequest.details,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
navigator.onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
// Start this after observing state machine
|
||||
LaunchedEffect(Unit) {
|
||||
observeVerificationService()
|
||||
}
|
||||
|
||||
fun handleEvents(event: IncomingVerificationViewEvents) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
|
|
@ -119,6 +140,7 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
|
||||
return IncomingVerificationState(
|
||||
step = step,
|
||||
request = verificationRequest,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
|
@ -129,36 +151,36 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
): Step =
|
||||
when (val machineState = this) {
|
||||
is StateMachineState.Initial,
|
||||
IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
|
||||
IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
||||
StateMachineState.AcceptingIncomingVerification,
|
||||
StateMachineState.RejectingIncomingVerification,
|
||||
null -> {
|
||||
Step.Initial(
|
||||
deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
|
||||
deviceDisplayName = sessionVerificationRequestDetails.senderProfile.displayName ?: sessionVerificationRequestDetails.deviceId.value,
|
||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
|
||||
machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
|
||||
isWaiting = machineState == StateMachineState.AcceptingIncomingVerification ||
|
||||
machineState == StateMachineState.RejectingIncomingVerification,
|
||||
)
|
||||
}
|
||||
is IncomingVerificationStateMachine.State.ChallengeReceived ->
|
||||
is StateMachineState.ChallengeReceived ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = false,
|
||||
)
|
||||
IncomingVerificationStateMachine.State.Completed -> Step.Completed
|
||||
IncomingVerificationStateMachine.State.Canceling,
|
||||
IncomingVerificationStateMachine.State.Failure -> Step.Failure
|
||||
is IncomingVerificationStateMachine.State.AcceptingChallenge ->
|
||||
StateMachineState.Completed -> Step.Completed
|
||||
StateMachineState.Canceling,
|
||||
StateMachineState.Failure -> Step.Failure
|
||||
is StateMachineState.AcceptingChallenge ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = true,
|
||||
)
|
||||
is IncomingVerificationStateMachine.State.RejectingChallenge ->
|
||||
is StateMachineState.RejectingChallenge ->
|
||||
Step.Verifying(
|
||||
data = machineState.data,
|
||||
isWaiting = true,
|
||||
)
|
||||
IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
|
||||
StateMachineState.Canceled -> Step.Canceled
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
|
|
@ -170,10 +192,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
VerificationFlowState.DidAcceptVerificationRequest,
|
||||
VerificationFlowState.DidStartSasVerification -> Unit
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
|
||||
stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.DidCancel -> {
|
||||
// Can happen when:
|
||||
|
|
@ -181,10 +203,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
// - another session has accepted the incoming verification request
|
||||
// - the user reject the challenge from this application (I think this is an error). In this case, the state
|
||||
// machine will ignore this event and change state to Failure.
|
||||
stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
|
||||
stateMachine.dispatch(StateMachineEvent.DidCancel)
|
||||
}
|
||||
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 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.VerificationRequest
|
||||
|
||||
@Immutable
|
||||
data class IncomingVerificationState(
|
||||
val step: Step,
|
||||
val request: VerificationRequest.Incoming,
|
||||
val eventSink: (IncomingVerificationViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
init {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.AcceptIncomingRequest, state ->
|
||||
on<Event.AcceptIncomingRequest> { _, state ->
|
||||
state.override { State.AcceptingIncomingVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
|
|
@ -39,23 +39,23 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
}
|
||||
}
|
||||
inState<State.ChallengeReceived> {
|
||||
on { _: Event.AcceptChallenge, state ->
|
||||
on<Event.AcceptChallenge> { _, state ->
|
||||
state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
|
||||
}
|
||||
on { _: Event.DeclineChallenge, state ->
|
||||
on<Event.DeclineChallenge> { _, state ->
|
||||
state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.AcceptingChallenge> {
|
||||
onEnterEffect { _ ->
|
||||
onEnterEffect {
|
||||
sessionVerificationService.approveVerification()
|
||||
}
|
||||
on { _: Event.DidAcceptChallenge, state ->
|
||||
on<Event.DidAcceptChallenge> { _, state ->
|
||||
state.override { State.Completed.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RejectingChallenge> {
|
||||
onEnterEffect { _ ->
|
||||
onEnterEffect {
|
||||
sessionVerificationService.declineVerification()
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
}
|
||||
inState {
|
||||
logReceivedEvents()
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
on<Event.Cancel> { _, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
State.Completed, State.Canceled -> state.noChange()
|
||||
else -> {
|
||||
|
|
@ -75,7 +75,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
on { _: Event.DidCancel, state: MachineState<State> ->
|
||||
on<Event.DidCancel> { _, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RejectingChallenge -> {
|
||||
state.override { State.Failure.andLogStateChange() }
|
||||
|
|
@ -91,7 +91,7 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
State.Failure -> state.noChange()
|
||||
}
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
on<Event.DidFail> { _, state: MachineState<State> ->
|
||||
state.override { State.Failure.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +128,11 @@ class IncomingVerificationStateMachine @Inject constructor(
|
|||
|
||||
/** Verification failure. */
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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.aEmojisSessionVerificationData
|
||||
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> {
|
||||
override val values: Sequence<IncomingVerificationState>
|
||||
get() = sequenceOf(
|
||||
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),
|
||||
verificationRequest = anIncomingUserVerificationRequest()
|
||||
),
|
||||
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.Completed),
|
||||
anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()),
|
||||
anIncomingVerificationState(step = Step.Failure),
|
||||
anIncomingVerificationState(step = Step.Canceled),
|
||||
// Add other state here
|
||||
|
|
@ -37,10 +53,38 @@ internal fun aStepInitial(
|
|||
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(
|
||||
step: Step = aStepInitial(),
|
||||
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest(),
|
||||
eventSink: (IncomingVerificationViewEvents) -> Unit = {},
|
||||
) = IncomingVerificationState(
|
||||
step = step,
|
||||
request = verificationRequest,
|
||||
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.ui.VerificationBottomMenu
|
||||
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.components.BigIcon
|
||||
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.PreviewsDayNight
|
||||
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.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
|
|
@ -59,10 +61,17 @@ fun IncomingVerificationView(
|
|||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
when {
|
||||
step is Step.Initial && !step.isWaiting -> Unit
|
||||
step is Step.Completed -> Unit
|
||||
else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) })
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
header = {
|
||||
IncomingVerificationHeader(step = step)
|
||||
IncomingVerificationHeader(step = step, request = state.request)
|
||||
},
|
||||
footer = {
|
||||
IncomingVerificationBottomMenu(
|
||||
|
|
@ -72,37 +81,58 @@ fun IncomingVerificationView(
|
|||
) {
|
||||
IncomingVerificationContent(
|
||||
step = step,
|
||||
request = state.request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingVerificationHeader(step: Step) {
|
||||
private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) {
|
||||
val iconStyle = when (step) {
|
||||
Step.Canceled -> BigIcon.Style.AlertSolid
|
||||
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
is Step.Initial -> if (step.isWaiting) {
|
||||
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.Failure -> BigIcon.Style.AlertSolid
|
||||
}
|
||||
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.Verifying -> when (step.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
Step.Completed -> R.string.screen_session_verification_request_success_title
|
||||
Step.Completed -> CommonStrings.common_verification_complete
|
||||
Step.Failure -> R.string.screen_session_verification_request_failure_title
|
||||
}
|
||||
val subtitleTextId = when (step) {
|
||||
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 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
|
||||
}
|
||||
PageTitle(
|
||||
|
|
@ -115,9 +145,10 @@ private fun IncomingVerificationHeader(step: Step) {
|
|||
@Composable
|
||||
private fun IncomingVerificationContent(
|
||||
step: Step,
|
||||
request: VerificationRequest.Incoming,
|
||||
) {
|
||||
when (step) {
|
||||
is Step.Initial -> ContentInitial(step)
|
||||
is Step.Initial -> ContentInitial(step, request)
|
||||
is Step.Verifying -> VerificationContentVerifying(step.data)
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -126,24 +157,40 @@ private fun IncomingVerificationContent(
|
|||
@Composable
|
||||
private fun ContentInitial(
|
||||
initialIncoming: Step.Initial,
|
||||
request: VerificationRequest.Incoming,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
SessionDetailsView(
|
||||
deviceName = initialIncoming.deviceDisplayName,
|
||||
deviceId = initialIncoming.deviceId,
|
||||
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp),
|
||||
text = stringResource(R.string.screen_session_verification_request_footer),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
when (request) {
|
||||
is VerificationRequest.Incoming.OtherSession -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
SessionDetailsView(
|
||||
deviceName = initialIncoming.deviceDisplayName,
|
||||
deviceId = initialIncoming.deviceId,
|
||||
signInFormattedTimestamp = initialIncoming.formattedSignInTime,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp),
|
||||
text = stringResource(R.string.screen_session_verification_request_footer),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
is Step.Initial -> {
|
||||
if (step.isWaiting) {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
InvisibleButton()
|
||||
}
|
||||
// Show nothing
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
|
|
@ -184,16 +222,7 @@ private fun IncomingVerificationBottomMenu(
|
|||
}
|
||||
is Step.Verifying -> {
|
||||
if (step.isWaiting) {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
showProgress = true,
|
||||
)
|
||||
InvisibleButton()
|
||||
}
|
||||
// Show nothing
|
||||
} else {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
|
||||
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.ui.Modifier
|
||||
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.AssistedInject
|
||||
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.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
|
|
@ -33,28 +28,22 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
|||
) : Node(buildContext, plugins = plugins) {
|
||||
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
showDeviceVerifiedScreen = inputs<VerifySessionEntryPoint.Params>().showDeviceVerifiedScreen,
|
||||
)
|
||||
private val inputs = inputs<VerifySessionEntryPoint.Params>()
|
||||
|
||||
private fun onLearnMoreClick(activity: Activity, dark: Boolean) {
|
||||
activity.openUrlInChromeCustomTab(null, dark, LearnMoreConfig.ENCRYPTION_URL)
|
||||
}
|
||||
private val presenter = presenterFactory.create(
|
||||
showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen,
|
||||
verificationRequest = inputs.verificationRequest,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onLearnMoreClick = {
|
||||
onLearnMoreClick(activity, isDark)
|
||||
},
|
||||
onEnterRecoveryKey = callback::onEnterRecoveryKey,
|
||||
onResetKey = callback::onResetKey,
|
||||
onLearnMoreClick = callback::onLearnMoreAboutEncryption,
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
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.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.RecoveryState
|
||||
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.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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
|
||||
class VerifySelfSessionPresenter @AssistedInject constructor(
|
||||
@Assisted private val showDeviceVerifiedScreen: Boolean,
|
||||
@Assisted private val verificationRequest: VerificationRequest.Outgoing,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val stateMachine: VerifySelfSessionStateMachine,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
) : Presenter<VerifySelfSessionState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(showDeviceVerifiedScreen: Boolean): VerifySelfSessionPresenter
|
||||
fun create(
|
||||
verificationRequest: VerificationRequest.Outgoing,
|
||||
showDeviceVerifiedScreen: Boolean,
|
||||
): VerifySelfSessionPresenter
|
||||
}
|
||||
|
||||
private val stateMachine = VerifySelfSessionStateMachine(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
|
||||
@Composable
|
||||
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 skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
|
||||
|
||||
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
||||
val signOutAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
if (skipVerification) {
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
} else {
|
||||
when (sessionVerifiedStatus) {
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||
SessionVerifiedStatus.NotVerified -> {
|
||||
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
|
||||
VerifySelfSessionState.Step.Completed
|
||||
} else {
|
||||
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
||||
VerifySelfSessionState.Step.Skipped
|
||||
when (verificationRequest) {
|
||||
is VerificationRequest.Outgoing.CurrentSession -> {
|
||||
when (sessionVerifiedStatus) {
|
||||
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
|
||||
SessionVerifiedStatus.NotVerified -> {
|
||||
stateAndDispatch.state.value.toVerificationStep()
|
||||
}
|
||||
SessionVerifiedStatus.Verified -> {
|
||||
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
|
||||
// The user has verified the session, we need to show the success screen
|
||||
VerifySelfSessionState.Step.Completed
|
||||
} else {
|
||||
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
|
||||
VerifySelfSessionState.Step.Exit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is VerificationRequest.Outgoing.User -> stateAndDispatch.state.value.toVerificationStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start this after observing state machine
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = true)
|
||||
|
||||
observeVerificationService()
|
||||
}
|
||||
|
||||
fun handleEvents(event: VerifySelfSessionViewEvents) {
|
||||
Timber.d("Verification user action: ${event::class.simpleName}")
|
||||
when (event) {
|
||||
VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
|
||||
// Just relay the event to the state machine
|
||||
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification(verificationRequest))
|
||||
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
|
||||
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
|
||||
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
||||
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
|
||||
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
|
||||
VerifySelfSessionViewEvents.SignOut -> coroutineScope.signOut(signOutAction)
|
||||
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
|
||||
sessionPreferencesStore.setSkipSessionVerification(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
return VerifySelfSessionState(
|
||||
step = step,
|
||||
signOutAction = signOutAction.value,
|
||||
displaySkipButton = buildMeta.isDebuggable,
|
||||
request = verificationRequest,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
canEnterRecoveryKey: Boolean
|
||||
): VerifySelfSessionState.Step =
|
||||
private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.Step =
|
||||
when (val machineState = this) {
|
||||
StateMachineState.Initial, null -> {
|
||||
VerifySelfSessionState.Step.Initial(
|
||||
canEnterRecoveryKey = canEnterRecoveryKey,
|
||||
isLastDevice = encryptionService.isLastDevice.value
|
||||
)
|
||||
VerifySelfSessionState.Step.Initial
|
||||
}
|
||||
VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
|
||||
VerifySelfSessionState.Step.UseAnotherDevice
|
||||
}
|
||||
StateMachineState.RequestingVerification,
|
||||
StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted,
|
||||
StateMachineState.Canceling -> {
|
||||
is StateMachineState.RequestingVerification,
|
||||
is StateMachineState.StartingSasVerification,
|
||||
StateMachineState.SasVerificationStarted -> {
|
||||
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +125,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
VerifySelfSessionState.Step.Ready
|
||||
}
|
||||
|
||||
StateMachineState.Canceled -> {
|
||||
is StateMachineState.Canceled -> {
|
||||
VerifySelfSessionState.Step.Canceled
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +140,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
StateMachineState.Completed -> {
|
||||
VerifySelfSessionState.Step.Completed
|
||||
}
|
||||
|
||||
StateMachineState.Exit -> {
|
||||
VerifySelfSessionState.Step.Exit
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeVerificationService() {
|
||||
|
|
@ -171,33 +151,27 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
|
|||
.onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
|
||||
.onEach { verificationAttemptState ->
|
||||
when (verificationAttemptState) {
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
|
||||
VerificationFlowState.Initial -> stateMachine.dispatch(StateMachineEvent.Reset)
|
||||
VerificationFlowState.DidAcceptVerificationRequest -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
|
||||
stateMachine.dispatch(StateMachineEvent.DidAcceptVerificationRequest)
|
||||
}
|
||||
VerificationFlowState.DidStartSasVerification -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
|
||||
stateMachine.dispatch(StateMachineEvent.DidStartSasVerification)
|
||||
}
|
||||
is VerificationFlowState.DidReceiveVerificationData -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
|
||||
stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data))
|
||||
}
|
||||
VerificationFlowState.DidFinish -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
|
||||
stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge)
|
||||
}
|
||||
VerificationFlowState.DidCancel -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
|
||||
stateMachine.dispatch(StateMachineEvent.DidCancel)
|
||||
}
|
||||
VerificationFlowState.DidFail -> {
|
||||
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
|
||||
stateMachine.dispatch(StateMachineEvent.DidFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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.Stable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
|
||||
@Immutable
|
||||
data class VerifySelfSessionState(
|
||||
val step: Step,
|
||||
val signOutAction: AsyncAction<Unit>,
|
||||
val displaySkipButton: Boolean,
|
||||
val request: VerificationRequest.Outgoing,
|
||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface Step {
|
||||
data object Loading : Step
|
||||
|
||||
// FIXME canEnterRecoveryKey value is never read.
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
|
||||
data object UseAnotherDevice : Step
|
||||
data object Initial : Step
|
||||
data object Canceled : Step
|
||||
data object AwaitingOtherDeviceResponse : Step
|
||||
data object Ready : Step
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : Step
|
||||
data object Completed : Step
|
||||
data object Skipped : Step
|
||||
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.verification.SessionVerificationData
|
||||
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.FlowPreview
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class VerifySelfSessionStateMachine @Inject constructor(
|
||||
class VerifySelfSessionStateMachine(
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
|
||||
initialState = State.Initial
|
||||
initialState = State.Initial,
|
||||
) {
|
||||
init {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.UseAnotherDevice, state ->
|
||||
state.override { State.UseAnotherDevice.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.UseAnotherDevice> {
|
||||
on { _: Event.RequestVerification, state ->
|
||||
state.override { State.RequestingVerification.andLogStateChange() }
|
||||
on<Event.RequestVerification> { event, state ->
|
||||
state.override { State.RequestingVerification(event.verificationRequest).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.RequestingVerification> {
|
||||
onEnterEffect {
|
||||
sessionVerificationService.requestVerification()
|
||||
onEnterEffect { event ->
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -61,25 +59,26 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
}
|
||||
}
|
||||
inState<State.VerificationRequestAccepted> {
|
||||
on { _: Event.StartSasVerification, state ->
|
||||
on<Event.StartSasVerification> { _, state ->
|
||||
state.override { State.StartingSasVerification.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Canceled> {
|
||||
on { _: Event.Reset, state ->
|
||||
on<Event.Reset> { _, state ->
|
||||
sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = false)
|
||||
state.override { State.Initial.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.SasVerificationStarted> {
|
||||
on { event: Event.DidReceiveChallenge, state ->
|
||||
on<Event.DidReceiveChallenge> { event, state ->
|
||||
state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
|
||||
}
|
||||
}
|
||||
inState<State.Verifying.ChallengeReceived> {
|
||||
on { _: Event.AcceptChallenge, state ->
|
||||
on<Event.AcceptChallenge> { _, state ->
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +90,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
sessionVerificationService.declineVerification()
|
||||
}
|
||||
}
|
||||
on { _: Event.DidAcceptChallenge, state ->
|
||||
on<Event.DidAcceptChallenge> { _, state ->
|
||||
// If a key backup exists, wait until it's restored or a timeout happens
|
||||
val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
|
||||
if (hasBackup) {
|
||||
|
|
@ -104,21 +103,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
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 {
|
||||
logReceivedEvents()
|
||||
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
|
||||
on<Event.DidStartSasVerification> { _, state: MachineState<State> ->
|
||||
state.override { State.SasVerificationStarted.andLogStateChange() }
|
||||
}
|
||||
on { _: Event.Cancel, state: MachineState<State> ->
|
||||
on<Event.Cancel> { event, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
State.Initial, State.Completed, State.Canceled -> state.noChange()
|
||||
State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
|
||||
State.Initial, State.Completed, is State.Canceled -> state.override { State.Exit }
|
||||
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
|
||||
// `Canceling` state to `Canceled` automatically anymore
|
||||
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() }
|
||||
}
|
||||
on { _: Event.DidFail, state: MachineState<State> ->
|
||||
when (state.snapshot) {
|
||||
is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
|
||||
else -> state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
on<Event.DidFail> { event, state: MachineState<State> ->
|
||||
state.override { State.Canceled.andLogStateChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/** Let the user know that they need to get ready on their other session. */
|
||||
data object UseAnotherDevice : State
|
||||
|
||||
/** Waiting for verification acceptance. */
|
||||
data object RequestingVerification : State
|
||||
data class RequestingVerification(val verificationRequest: VerificationRequest.Outgoing) : State
|
||||
|
||||
/** Verification request accepted. Waiting for start. */
|
||||
data object VerificationRequestAccepted : State
|
||||
|
|
@ -167,22 +153,18 @@ class VerifySelfSessionStateMachine @Inject constructor(
|
|||
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. */
|
||||
data object Canceled : State
|
||||
|
||||
/** Verification successful. */
|
||||
data object Completed : State
|
||||
|
||||
data object Exit : State
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
/** User wants to use another session. */
|
||||
data object UseAnotherDevice : Event
|
||||
|
||||
/** Request verification. */
|
||||
data object RequestVerification : Event
|
||||
data class RequestVerification(val verificationRequest: VerificationRequest.Outgoing) : Event
|
||||
|
||||
/** The current verification request has been accepted. */
|
||||
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.ui.aDecimalsSessionVerificationData
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
|
||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
||||
override val values: Sequence<VerifySelfSessionState>
|
||||
get() = sequenceOf(
|
||||
aVerifySelfSessionState(displaySkipButton = true),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.AwaitingOtherDeviceResponse
|
||||
step = Step.Initial,
|
||||
request = anOutgoingSessionVerificationRequest(),
|
||||
),
|
||||
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(
|
||||
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||
|
|
@ -37,40 +55,32 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
|
|||
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Initial(canEnterRecoveryKey = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
|
||||
step = Step.Completed,
|
||||
request = anOutgoingSessionVerificationRequest(),
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Completed,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
signOutAction = AsyncAction.Loading,
|
||||
displaySkipButton = true,
|
||||
request = anOutgoingUserVerificationRequest(),
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Loading
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.Skipped
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
step = Step.UseAnotherDevice
|
||||
step = Step.Exit
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com"))
|
||||
internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession
|
||||
|
||||
internal fun aVerifySelfSessionState(
|
||||
step: Step = Step.Initial(canEnterRecoveryKey = false),
|
||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
displaySkipButton: Boolean = false,
|
||||
step: Step = Step.Initial,
|
||||
request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
|
||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
||||
) = VerifySelfSessionState(
|
||||
step = step,
|
||||
displaySkipButton = displaySkipButton,
|
||||
request = request,
|
||||
eventSink = eventSink,
|
||||
signOutAction = signOutAction,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,12 +17,8 @@ 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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.ui.VerificationBottomMenu
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
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.PreviewsDayNight
|
||||
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.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.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -55,18 +50,16 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
fun VerifySelfSessionView(
|
||||
state: VerifySelfSessionState,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step = state.step
|
||||
fun cancelOrResetFlow() {
|
||||
when (step) {
|
||||
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
is Step.AwaitingOtherDeviceResponse,
|
||||
Step.UseAnotherDevice,
|
||||
Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
Step.Initial, Step.Completed -> onBack()
|
||||
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
is Step.Verifying -> {
|
||||
if (!step.state.isLoading()) {
|
||||
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 {
|
||||
cancelOrResetFlow()
|
||||
}
|
||||
|
||||
if (step is Step.Loading ||
|
||||
step is Step.Skipped) {
|
||||
if (step is Step.Loading) {
|
||||
// Just display a loader in this case, to avoid UI glitch.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -101,94 +87,94 @@ fun VerifySelfSessionView(
|
|||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
if (step !is Step.Completed &&
|
||||
state.displaySkipButton &&
|
||||
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) }
|
||||
)
|
||||
}
|
||||
navigationIcon = if (step != Step.Completed) {
|
||||
{ BackButton(onClick = ::cancelOrResetFlow) }
|
||||
} else {
|
||||
{}
|
||||
}
|
||||
)
|
||||
},
|
||||
header = {
|
||||
VerifySelfSessionHeader(step = step)
|
||||
VerifySelfSessionHeader(step = step, request = state.request)
|
||||
},
|
||||
footer = {
|
||||
VerifySelfSessionBottomMenu(
|
||||
screenState = state,
|
||||
onCancelClick = ::cancelOrResetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onContinueClick = onFinish,
|
||||
onResetKey = onResetKey,
|
||||
)
|
||||
}
|
||||
) {
|
||||
VerifySelfSessionContent(
|
||||
flowState = step,
|
||||
request = state.request,
|
||||
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
|
||||
private fun VerifySelfSessionHeader(step: Step) {
|
||||
private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Outgoing) {
|
||||
val iconStyle = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
|
||||
Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
|
||||
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
|
||||
Step.Initial -> when (request) {
|
||||
is VerificationRequest.Outgoing.CurrentSession -> 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.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
Step.Ready -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
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) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> R.string.screen_identity_confirmation_title
|
||||
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
|
||||
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
|
||||
Step.Initial -> when (request) {
|
||||
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_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.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 SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
is Step.Skipped -> return
|
||||
is Step.Exit -> return
|
||||
}
|
||||
val subtitleTextId = when (step) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
is Step.Initial -> R.string.screen_identity_confirmation_subtitle
|
||||
Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
|
||||
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
|
||||
Step.Initial -> when (request) {
|
||||
is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_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.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 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(
|
||||
|
|
@ -201,11 +187,15 @@ private fun VerifySelfSessionHeader(step: Step) {
|
|||
@Composable
|
||||
private fun VerifySelfSessionContent(
|
||||
flowState: Step,
|
||||
request: VerificationRequest.Outgoing,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
) {
|
||||
when (flowState) {
|
||||
is Step.Initial -> {
|
||||
ContentInitial(onLearnMoreClick)
|
||||
when (request) {
|
||||
is VerificationRequest.Outgoing.CurrentSession -> Unit
|
||||
is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick)
|
||||
}
|
||||
}
|
||||
is Step.Verifying -> {
|
||||
VerificationContentVerifying(flowState.data)
|
||||
|
|
@ -235,8 +225,6 @@ private fun ContentInitial(
|
|||
@Composable
|
||||
private fun VerifySelfSessionBottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
) {
|
||||
|
|
@ -248,27 +236,6 @@ private fun VerifySelfSessionBottomMenu(
|
|||
when (verificationViewState) {
|
||||
Step.Loading -> error("Should not happen")
|
||||
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 {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -302,39 +269,20 @@ private fun VerifySelfSessionBottomMenu(
|
|||
)
|
||||
}
|
||||
}
|
||||
is Step.AwaitingOtherDeviceResponse -> {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
onClick = {},
|
||||
showProgress = true,
|
||||
enabled = false,
|
||||
)
|
||||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.AwaitingOtherDeviceResponse -> Unit
|
||||
is Step.Verifying -> {
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
|
||||
if (isVerifying) {
|
||||
// Show nothing
|
||||
} else {
|
||||
stringResource(R.string.screen_session_verification_they_match)
|
||||
}
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
showProgress = isVerifying,
|
||||
enabled = !isVerifying,
|
||||
onClick = {
|
||||
if (!isVerifying) {
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_match),
|
||||
onClick = {
|
||||
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isVerifying) {
|
||||
InvisibleButton()
|
||||
} else {
|
||||
},
|
||||
)
|
||||
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
|
|
@ -353,7 +301,7 @@ private fun VerifySelfSessionBottomMenu(
|
|||
InvisibleButton()
|
||||
}
|
||||
}
|
||||
is Step.Skipped -> return
|
||||
is Step.Exit -> return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,8 +311,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
|
|||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onLearnMoreClick = {},
|
||||
onEnterRecoveryKey = {},
|
||||
onResetKey = {},
|
||||
onFinish = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,10 @@
|
|||
package io.element.android.features.verifysession.impl.outgoing
|
||||
|
||||
sealed interface VerifySelfSessionViewEvents {
|
||||
data object UseAnotherDevice : VerifySelfSessionViewEvents
|
||||
data object RequestVerification : VerifySelfSessionViewEvents
|
||||
data object StartSasVerification : VerifySelfSessionViewEvents
|
||||
data object ConfirmVerification : VerifySelfSessionViewEvents
|
||||
data object DeclineVerification : VerifySelfSessionViewEvents
|
||||
data object Cancel : 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.SessionVerificationService
|
||||
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_TIMESTAMP
|
||||
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.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -37,7 +39,7 @@ class IncomingVerificationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val approveVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
|
|
@ -60,7 +62,7 @@ class IncomingVerificationPresenterTest {
|
|||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
|
|
@ -100,7 +102,7 @@ class IncomingVerificationPresenterTest {
|
|||
|
||||
@Test
|
||||
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 declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
|
|
@ -123,7 +125,7 @@ class IncomingVerificationPresenterTest {
|
|||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
|
|
@ -157,7 +159,7 @@ class IncomingVerificationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - incoming verification is remotely canceled`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
|
|
@ -191,7 +193,7 @@ class IncomingVerificationPresenterTest {
|
|||
|
||||
@Test
|
||||
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 declineVerificationLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
|
|
@ -214,7 +216,7 @@ class IncomingVerificationPresenterTest {
|
|||
)
|
||||
)
|
||||
resetLambda.assertions().isCalledOnce().with(value(false))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
|
||||
acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest))
|
||||
acceptVerificationRequestLambda.assertions().isNeverCalled()
|
||||
// User accept the incoming verification
|
||||
initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
|
||||
|
|
@ -248,7 +250,7 @@ class IncomingVerificationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - user ignores incoming request`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<SessionVerificationRequestDetails, Unit> { _ -> }
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||
val acceptVerificationRequestLambda = lambdaRecorder<Unit> { }
|
||||
val resetLambda = lambdaRecorder<Boolean, Unit> { }
|
||||
val fakeSessionVerificationService = FakeSessionVerificationService(
|
||||
|
|
@ -268,24 +270,30 @@ class IncomingVerificationPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
|
||||
senderId = A_USER_ID,
|
||||
flowId = FlowId("flowId"),
|
||||
deviceId = A_DEVICE_ID,
|
||||
displayName = "a device name",
|
||||
firstSeenTimestamp = A_TIMESTAMP,
|
||||
private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
|
||||
details = SessionVerificationRequestDetails(
|
||||
senderProfile = SessionVerificationRequestDetails.SenderProfile(
|
||||
userId = A_USER_ID,
|
||||
displayName = "a device name",
|
||||
avatarUrl = null,
|
||||
),
|
||||
flowId = FlowId("flowId"),
|
||||
deviceId = A_DEVICE_ID,
|
||||
firstSeenTimestamp = A_TIMESTAMP,
|
||||
)
|
||||
)
|
||||
|
||||
private fun createPresenter(
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
|
||||
private fun TestScope.createPresenter(
|
||||
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
|
||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
) = IncomingVerificationPresenter(
|
||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
||||
verificationRequest = verificationRequest,
|
||||
navigator = navigator,
|
||||
sessionVerificationService = service,
|
||||
stateMachine = IncomingVerificationStateMachine(service),
|
||||
dateFormatter = dateFormatter,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,27 +9,21 @@ package io.element.android.features.verifysession.impl.outgoing
|
|||
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
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.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.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
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.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -48,85 +42,70 @@ class VerifySelfSessionPresenterTest {
|
|||
)
|
||||
presenter.test {
|
||||
awaitItem().run {
|
||||
assertThat(step).isEqualTo(Step.Initial(false))
|
||||
assertThat(displaySkipButton).isTrue()
|
||||
assertThat(step).isEqualTo(Step.Initial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
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 {
|
||||
fun `present - Handles requestVerification for session verification`() = runTest {
|
||||
val requestSessionVerificationRecorder = lambdaRecorder<Unit> {}
|
||||
val startVerificationRecorder = lambdaRecorder<Unit> {}
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
requestSessionVerificationLambda = requestSessionVerificationRecorder,
|
||||
startVerificationLambda = startVerificationRecorder,
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
service = service,
|
||||
verificationRequest = anOutgoingSessionVerificationRequest(),
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
presenter.test {
|
||||
requestVerificationAndAwaitVerifyingState(service)
|
||||
|
||||
requestSessionVerificationRecorder.assertions().isCalledOnce()
|
||||
startVerificationRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
service = unverifiedSessionService(),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial(false))
|
||||
assertThat(initialState.step).isEqualTo(Step.Initial)
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Exit)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
|
|
@ -143,24 +122,23 @@ class VerifySelfSessionPresenterTest {
|
|||
}
|
||||
|
||||
@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(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
|
||||
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
service.emitVerificationFlowState(VerificationFlowState.DidFail)
|
||||
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
cancelVerificationLambda = { },
|
||||
)
|
||||
|
|
@ -175,7 +153,7 @@ class VerifySelfSessionPresenterTest {
|
|||
@Test
|
||||
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
|
|
@ -189,7 +167,7 @@ class VerifySelfSessionPresenterTest {
|
|||
@Test
|
||||
fun `present - Go back after cancellation returns to initial state`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
)
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
|
|
@ -199,7 +177,7 @@ class VerifySelfSessionPresenterTest {
|
|||
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
// Went back to initial state
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Initial)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
@ -210,7 +188,7 @@ class VerifySelfSessionPresenterTest {
|
|||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||
)
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
approveVerificationLambda = { },
|
||||
)
|
||||
|
|
@ -235,7 +213,7 @@ class VerifySelfSessionPresenterTest {
|
|||
@Test
|
||||
fun `present - When verification is declined, the flow is canceled`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
requestVerificationLambda = { },
|
||||
requestSessionVerificationLambda = { },
|
||||
startVerificationLambda = { },
|
||||
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
|
||||
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
|
||||
val service = FakeSessionVerificationService(
|
||||
|
|
@ -301,32 +265,7 @@ class VerifySelfSessionPresenterTest {
|
|||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
|
||||
val service = FakeSessionVerificationService(
|
||||
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))
|
||||
assertThat(awaitItem().step).isEqualTo(Step.Exit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,10 +274,7 @@ class VerifySelfSessionPresenterTest {
|
|||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||
): VerifySelfSessionState {
|
||||
var state = awaitItem()
|
||||
assertThat(state.step).isEqualTo(Step.Initial(false))
|
||||
state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
|
||||
state = awaitItem()
|
||||
assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
|
||||
assertThat(state.step).isEqualTo(Step.Initial)
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
|
|
@ -360,17 +296,19 @@ class VerifySelfSessionPresenterTest {
|
|||
}
|
||||
|
||||
private suspend fun unverifiedSessionService(
|
||||
requestVerificationLambda: () -> Unit = { lambdaError() },
|
||||
requestSessionVerificationLambda: () -> Unit = { lambdaError() },
|
||||
requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
|
||||
cancelVerificationLambda: () -> Unit = { lambdaError() },
|
||||
approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
resetLambda: (Boolean) -> Unit = { },
|
||||
acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
|
||||
acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService(
|
||||
requestVerificationLambda = requestVerificationLambda,
|
||||
requestCurrentSessionVerificationLambda = requestSessionVerificationLambda,
|
||||
requestUserVerificationLambda = requestUserVerificationLambda,
|
||||
cancelVerificationLambda = cancelVerificationLambda,
|
||||
approveVerificationLambda = approveVerificationLambda,
|
||||
declineVerificationLambda = declineVerificationLambda,
|
||||
|
|
@ -385,20 +323,15 @@ class VerifySelfSessionPresenterTest {
|
|||
|
||||
private fun createVerifySelfSessionPresenter(
|
||||
service: SessionVerificationService,
|
||||
verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
|
||||
showDeviceVerifiedScreen: Boolean = false,
|
||||
): VerifySelfSessionPresenter {
|
||||
return VerifySelfSessionPresenter(
|
||||
showDeviceVerifiedScreen = showDeviceVerifiedScreen,
|
||||
verificationRequest = verificationRequest,
|
||||
sessionVerificationService = service,
|
||||
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.ui.strings.CommonStrings
|
||||
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.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
|
|
@ -25,7 +24,6 @@ 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 VerifySelfSessionViewTest {
|
||||
|
|
@ -103,16 +101,16 @@ class VerifySelfSessionViewTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `back key pressed - on Completed step does nothing`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertEmpty()
|
||||
fun `back key pressed - on Completed exits the flow`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
onBack = callback,
|
||||
state = aVerifySelfSessionState(
|
||||
step = VerifySelfSessionState.Step.Completed,
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
fun `clicking on they match emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
|
|
@ -194,48 +160,18 @@ class VerifySelfSessionViewTest {
|
|||
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(
|
||||
state: VerifySelfSessionState,
|
||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
||||
onFinished: () -> Unit = EnsureNeverCalled(),
|
||||
onResetKey: () -> Unit = EnsureNeverCalled(),
|
||||
onBack: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onFinish = onFinished,
|
||||
onResetKey = onResetKey,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ android {
|
|||
implementation(libs.coil.compose)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
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.libraries.designsystem.preview.ElementPreview
|
||||
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.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -78,6 +79,11 @@ object BigIcon {
|
|||
* A success style with a tinted background.
|
||||
*/
|
||||
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.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
|
||||
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
|
||||
}
|
||||
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
|
||||
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
@ -134,12 +116,50 @@ object BigIcon {
|
|||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = iconTint,
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
if (style is Style.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(27.dp),
|
||||
color = ElementTheme.colors.iconSecondary,
|
||||
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.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
|
||||
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),
|
||||
|
||||
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
|
||||
data class SessionVerificationRequestDetails(
|
||||
val senderId: UserId,
|
||||
val senderProfile: SenderProfile,
|
||||
val flowId: FlowId,
|
||||
val deviceId: DeviceId,
|
||||
val displayName: String?,
|
||||
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
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
|
@ -31,7 +32,12 @@ interface SessionVerificationService {
|
|||
/**
|
||||
* 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.
|
||||
|
|
@ -67,7 +73,7 @@ interface SessionVerificationService {
|
|||
* Set this particular request as the currently active one and register for
|
||||
* events pertaining it.
|
||||
*/
|
||||
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
|
||||
suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming)
|
||||
|
||||
/**
|
||||
* Accept the previously acknowledged verification request.
|
||||
|
|
@ -76,7 +82,7 @@ interface SessionVerificationService {
|
|||
}
|
||||
|
||||
interface SessionVerificationServiceListener {
|
||||
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
|
||||
fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming)
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
|
|
@ -50,6 +51,8 @@ class RustSessionVerificationService(
|
|||
isSyncServiceReady: Flow<Boolean>,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
||||
private var currentVerificationRequest: VerificationRequest? = null
|
||||
|
||||
private val encryptionService: Encryption = client.encryption()
|
||||
private lateinit var verificationController: SessionVerificationController
|
||||
|
||||
|
|
@ -88,10 +91,8 @@ class RustSessionVerificationService(
|
|||
verificationStatus == SessionVerifiedStatus.NotVerified
|
||||
}
|
||||
|
||||
private var isOwnVerification = true
|
||||
|
||||
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
|
||||
listener?.onIncomingSessionRequest(details.map())
|
||||
listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId())))
|
||||
}
|
||||
|
||||
private var listener: SessionVerificationServiceListener? = null
|
||||
|
|
@ -111,9 +112,16 @@ class RustSessionVerificationService(
|
|||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun requestVerification() = tryOrFail {
|
||||
override suspend fun requestCurrentSessionVerification() = tryOrFail {
|
||||
initVerificationControllerIfNeeded()
|
||||
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 {
|
||||
|
|
@ -130,16 +138,16 @@ class RustSessionVerificationService(
|
|||
verificationController.startSasVerification()
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
|
||||
isOwnVerification = false
|
||||
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail {
|
||||
initVerificationControllerIfNeeded()
|
||||
verificationController.acknowledgeVerificationRequest(
|
||||
senderId = details.senderId.value,
|
||||
flowId = details.flowId.value,
|
||||
senderId = verificationRequest.details.senderProfile.userId.value,
|
||||
flowId = verificationRequest.details.flowId.value,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = tryOrFail {
|
||||
Timber.d("Accepting incoming verification request")
|
||||
verificationController.acceptVerificationRequest()
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +191,7 @@ class RustSessionVerificationService(
|
|||
}
|
||||
}
|
||||
.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
|
||||
tryOrNull {
|
||||
withTimeout(10.seconds) {
|
||||
|
|
@ -215,7 +223,7 @@ class RustSessionVerificationService(
|
|||
// end-region
|
||||
|
||||
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
|
||||
isOwnVerification = true
|
||||
currentVerificationRequest = null
|
||||
if (isReady.value && cancelAnyPendingVerificationAttempt) {
|
||||
// Cancel any pending verification attempt
|
||||
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.UserId
|
||||
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.UserProfile as RustUserProfile
|
||||
|
||||
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
||||
senderId = UserId(senderProfile.userId),
|
||||
senderProfile = senderProfile.map(),
|
||||
flowId = FlowId(flowId),
|
||||
deviceId = DeviceId(deviceId),
|
||||
displayName = senderProfile.displayName,
|
||||
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
|
||||
|
||||
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.SessionVerificationServiceListener
|
||||
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.VerificationRequest
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -20,13 +21,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
|
||||
class FakeSessionVerificationService(
|
||||
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 approveVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val declineVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val startVerificationLambda: () -> Unit = { lambdaError() },
|
||||
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
|
||||
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
|
||||
private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
|
||||
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
|
||||
) : SessionVerificationService {
|
||||
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
|
||||
|
|
@ -37,8 +39,12 @@ class FakeSessionVerificationService(
|
|||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
|
||||
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
|
||||
|
||||
override suspend fun requestVerification() {
|
||||
requestVerificationLambda()
|
||||
override suspend fun requestCurrentSessionVerification() {
|
||||
requestCurrentSessionVerificationLambda()
|
||||
}
|
||||
|
||||
override suspend fun requestUserVerification(userId: UserId) {
|
||||
requestUserVerificationLambda(userId)
|
||||
}
|
||||
|
||||
override suspend fun cancelVerification() {
|
||||
|
|
@ -68,8 +74,8 @@ class FakeSessionVerificationService(
|
|||
this.listener = listener
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||
acknowledgeVerificationRequestLambda(details)
|
||||
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||
acknowledgeVerificationRequestLambda(verificationRequest)
|
||||
}
|
||||
|
||||
override suspend fun acceptVerificationRequest() = simulateLongTask {
|
||||
|
|
|
|||
|
|
@ -11,4 +11,8 @@ plugins {
|
|||
|
||||
android {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
oid sha256:7479bd11493b82e7027599a938979fd50fb75c31cf7c505cf8669db9387fb124
|
||||
size 38805
|
||||
oid sha256:f1a69af9c184578a355345037ec34016c4b4ab26a2719bfaffafa1027365a678
|
||||
size 38453
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56418af183e3287631cead30c1077d5734ff59f014c03e2ea0df8d91d38ade01
|
||||
size 42255
|
||||
oid sha256:c41fdaa2341788494026277683590e0fe58fcc6b4c93919c77dcf29fd1e95c15
|
||||
size 41752
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:56c21416493a55bb9f105a9908f2b9e07441ec5f5ae3afb68f59fb8329cdb242
|
||||
size 39527
|
||||
oid sha256:e99e06f6281be006fb12e0a56e7523c8144256562464474cb2aa918f6576d636
|
||||
size 39091
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:128bc1d07c659dfdb25a5cac2513ef3dfcad6a156fd3a38457eec40a735e74c9
|
||||
size 43265
|
||||
oid sha256:d59deaee5d608c279670104c0f3bfcb5bbda90ac9779111441053ea1b9d40d4c
|
||||
size 42658
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f41090fceca5845f88dbbc6b983e6330ecd3b86d2056ba4584f1fca26305bb0a
|
||||
size 28943
|
||||
oid sha256:4e395d9ca4fc6b2eb1ea9a8fd6e2ad34e7397d9f64cf39e05eed50a1bfffabd7
|
||||
size 24186
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0516c0355081b7b2d745ec46fd160fd566be224ef7d350849c9990a8c6320563
|
||||
size 27018
|
||||
oid sha256:87cb4255c8346796a226cdfc2afb67641a3b27c2c1a15629a07c3293fcad5131
|
||||
size 22078
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f20c32a85bf93f25c51c18b79adc0f8237173dc9fe0705570634adbcc94b1350
|
||||
size 36484
|
||||
oid sha256:ba80787576dd893f90fcdc83021e47abbde4ae2bcaaca2c167178ba7c2be3886
|
||||
size 36481
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5cc7bdca7fe74fe0b16ab23eb4866b3af5245ed762c74a1edba087070cfe32b8
|
||||
size 30448
|
||||
oid sha256:3a2278fd516dff3a5c86702f09d5621e8b3bb3de1df702b5036fa114975e4d85
|
||||
size 27844
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfc7c5d99c0d1e5e0194a5961aac43efea94ae100fca0249f6b00e20b47cbb09
|
||||
size 26565
|
||||
oid sha256:a465e6254ae595d62a335914414ad2f33b76736f529d1e188f3d0e0d57268f65
|
||||
size 23015
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2f9d02116554413601c8b11fb75581f30ebb6ce5b8ade2a5a62a0aa4ce04705
|
||||
size 29914
|
||||
oid sha256:ef88b209eb93fb6b4e5aa411c4cdcb9ff1aa49bd44f11c2271a4d758482b145a
|
||||
size 25181
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f86dd19d8056f16ae1a5337c49f35d420f2b83baafb29360c8c07c3347d5a93f
|
||||
size 28074
|
||||
oid sha256:6894ddb5662e7da82e5ee42fb0c9eb30afd87e15e415ba42be218ebab434d53a
|
||||
size 23531
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:46d3c77fff43081041fb952fe9c8ea733c5ec164ca9aca548924d3cbefb52369
|
||||
size 26131
|
||||
oid sha256:0f393f620cc11b7aec7bf52f7efc5a5872ed07a14e7fbf2149b16171dbb2b03e
|
||||
size 21463
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7fdbd16b4e78f890f60668fccfbe7af6c6a4f5ba22af26e9f3735dafb13e9d8f
|
||||
size 33700
|
||||
oid sha256:ce136a49edadeaadc229ec485b27bf2d4ae20601e948bca46c632a4035b90130
|
||||
size 33697
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79f2c2bb7772dd91b37351e4d1e658155f341d83cb6d496eded65b0b543fa2b1
|
||||
size 27898
|
||||
oid sha256:580fc1fe77975e83cf83fe582c2f3f4aa7003cf302e7dadcd6bb97c04cbbf148
|
||||
size 25517
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4acb962472308c94998b3bc67d71a6a69b9bad0294461df374f7e74dcf703328
|
||||
size 24379
|
||||
oid sha256:4b48c3e2c5e06a612473e252ba52eead6efd741b7ea21fdb778ec9ed5952a2a2
|
||||
size 21181
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a409716052c9c12c963668098943b8f286e41d6a10f5639d11b7b464cb9d0c3d
|
||||
size 28909
|
||||
oid sha256:201c3f2385059321f7500ca8d559bb973a51aeb56fd417e943be496ae55d0869
|
||||
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
|
||||
oid sha256:2473606524b856b4afd81e4a32b29c7456788746078387fff280f3c8eb577396
|
||||
size 41622
|
||||
oid sha256:1da0edef645c5abc391f0425e3a1f8719c1f061f66517b131a81cdd59d2bd04a
|
||||
size 40105
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a8c359687b3ec5f038602b9022ef3967af12c6632a59c56bd10fecf7d92233c
|
||||
size 46457
|
||||
oid sha256:8ebb02ae3454f3cbb28e3aac886d088888251d25ba2c5e8fb7adde700f10383e
|
||||
size 36323
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b6576c0b77984df1930f0115e1ea90e6720f0fb5f500e186c591f0fd2eb771b6
|
||||
size 44729
|
||||
oid sha256:682472baf2a1bc5f99cfadc3177615167f1f7531923ec34374f59e263b50d79f
|
||||
size 37049
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90c8e131e639b6638a75468d41a3ff0107822e3450c6edbcc4d9eccdd180f6ae
|
||||
size 31537
|
||||
oid sha256:f8aa70b0d5eace900fc8c58674d2f3cdcf409335a33e8cf3eea5f60fb495d009
|
||||
size 32299
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3fc2ecdf5b8880b67e9ee9eacba9636819396236325f6ce28f0bf5755fa5ae5e
|
||||
size 21892
|
||||
oid sha256:c28294b67c1d8f448300b35a4ec6ebecd105334375af61b61612b82b5509d417
|
||||
size 46402
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b
|
||||
size 24134
|
||||
oid sha256:becc11517522d030f4539c67d7b4728661135a43b05e9c902481b79539155912
|
||||
size 46738
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8371da870ec795fbced08edd199c6e6c53b1557b648686fe89c278def41e425b
|
||||
size 24134
|
||||
oid sha256:baa21d8011741ac2bca82ac26fa289d016a3ddcc16d408f92812adc662e27869
|
||||
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