Add Session Verification flow (#197)

This commit is contained in:
Jorge Martin Espinosa 2023-03-17 10:07:19 +01:00 committed by GitHub
parent 1795a844a1
commit dcb98f06aa
76 changed files with 2347 additions and 35 deletions

View file

@ -29,4 +29,7 @@ java {
dependencies {
implementation(libs.coroutines.core)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}

View file

@ -0,0 +1,192 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.statemachine
import io.element.android.libraries.core.bool.orFalse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
fun <Event : Any, State : Any> createStateMachine(
config: StateMachineBuilder<Event, State>.() -> Unit
): StateMachine<Event, State> {
val builder = StateMachineBuilder<Event, State>()
config(builder)
return builder.build()
}
class StateMachine<Event : Any, State : Any>(
val initialState: State,
private val stateConfigs: Map<Class<*>, StateConfig<*>>,
private val routes: List<StateMachineRoute<*, *, *>>,
) {
private val _stateFlow = MutableStateFlow(initialState)
val stateFlow = _stateFlow.asStateFlow()
val currentState: State get() = stateFlow.value
var transitionHandler: ((State, Event, State) -> Unit)? = null
init {
@Suppress("UNCHECKED_CAST")
val initialStateConfig = stateConfigs[initialState::class.java] as StateConfig<State>
initialStateConfig.onEnter?.invoke(initialState)
}
@Suppress("UNCHECKED_CAST")
fun <E : Event> process(event: E) {
val route = findMatchingRoute(event) ?: error("No route found for state $currentState on event $event")
val lastStateConfig: StateConfig<State>? = stateConfigs[currentState::class.java] as? StateConfig<State>
lastStateConfig?.onExit?.invoke(currentState)
val nextState = route.toState(event, currentState)
transitionHandler?.invoke(currentState, event, nextState)
_stateFlow.value = nextState
val currentStateConfig = stateConfigs[nextState::class.java] as? StateConfig<State>
currentStateConfig?.onEnter?.invoke(nextState)
}
private fun <E : Event> findMatchingRoute(event: E): StateMachineRoute<E, State, State>? {
val routesForEvent = routes.filter { it.eventType.isInstance(event) }
return (routesForEvent.firstOrNull { it.fromState?.isInstance(currentState).orFalse() }
?: routesForEvent.firstOrNull { it.fromState == null }) as? StateMachineRoute<E, State, State>
}
fun restart() {
_stateFlow.value = initialState
}
}
class StateMachineBuilder<Event : Any, State : Any>(
val routes: MutableList<StateMachineRoute<out Event, out State, out State>> = mutableListOf(),
) {
lateinit var initialState: State
var stateConfigs = mutableMapOf<Class<out State>, StateConfig<out State>>()
inline fun <reified S : State> addState(block: StateRegistrationBuilder<Event, State, S>.() -> Unit = {}) {
val config = StateConfig(S::class.java)
val registrationBuilder = StateRegistrationBuilder<Event, State, S>(config)
block(registrationBuilder)
verifyRoutesAreUnique(S::class.java, routes, registrationBuilder.routes)
if (stateConfigs.contains(S::class.java)) {
error("Duplicate registration for state ${S::class.java.name}")
}
stateConfigs[S::class.java] = config
routes.addAll(registrationBuilder.routes)
}
inline fun <reified S : State> addInitialState(state: S, config: StateRegistrationBuilder<Event, State, S>.() -> Unit = {}) {
initialState = state
addState(block = config)
}
inline fun <reified E : Event, reified S : State> on(noinline configuration: (E, State) -> S) {
val builder = RouteBuilder<E, State, S>(E::class.java, null)
builder.toState = configuration
val newRoute = builder.build()
verifyRoutesAreUnique(S::class.java, routes, listOf(newRoute))
routes.add(newRoute)
}
inline fun <reified E : Event> on(newState: State) {
val builder = RouteBuilder<E, State, State>(E::class.java, null)
builder.toState = { _, _ -> newState }
val newRoute = builder.build()
verifyRoutesAreUnique(null, routes, listOf(newRoute))
routes.add(newRoute)
}
fun build(): StateMachine<Event, State> {
if (::initialState.isInitialized) {
return StateMachine(initialState, stateConfigs.toMap(), routes)
} else {
error("The state machine has no initial state")
}
}
companion object {
fun verifyRoutesAreUnique(
state: Class<*>?,
oldRoutes: List<StateMachineRoute<*, *, *>>,
newRoutes: List<StateMachineRoute<*, *, *>>,
) {
val oldEvents = oldRoutes.filter { it.fromState == state }.map { it.eventType }
val newEvents = newRoutes.filter { it.fromState == state }.map { it.eventType }
val intersection = oldEvents.intersect(newEvents)
if (intersection.isNotEmpty()) {
val duplicates = intersection.joinToString(", ") { it.name }
error("Duplicate registration in state ${state?.name} for events: $duplicates")
}
}
}
}
class StateRegistrationBuilder<Event : Any, BaseState : Any, State : BaseState>(
val fromState: StateConfig<State>,
val routes: MutableList<StateMachineRoute<out Event, out State, out BaseState>> = mutableListOf(),
) {
fun onEnter(enter: (State) -> Unit) {
fromState.onEnter = enter
}
fun onExit(exit: (State) -> Unit) {
fromState.onExit = exit
}
inline fun <reified E : Event> on(noinline configuration: (E, State) -> BaseState) {
val builder = RouteBuilder<E, State, BaseState>(E::class.java, fromState.state)
builder.toState = configuration
val newRoute = builder.build()
StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute))
routes.add(newRoute)
}
inline fun <reified E : Event> on(newState: BaseState) {
val builder = RouteBuilder<E, State, BaseState>(E::class.java, fromState.state)
builder.toState = { _, _ -> newState }
val newRoute = builder.build()
StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute))
routes.add(newRoute)
}
}
class RouteBuilder<Event : Any, FromState : Any, ToState : Any>(
val eventType: Class<out Event>,
val fromState: Class<out FromState>?,
) {
lateinit var toState: (Event, FromState) -> ToState
fun build() = StateMachineRoute(eventType, fromState, toState)
}
data class StateMachineRoute<Event : Any, FromState : Any, ToState : Any>(
val eventType: Class<out Event>,
val fromState: Class<out FromState>?,
val toState: (Event, FromState) -> ToState,
)
data class StateConfig<State : Any>(
val state: Class<State>,
var onEnter: ((State) -> Unit)? = null,
var onExit: ((State) -> Unit)? = null,
)

View file

@ -0,0 +1,202 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.statemachine
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.fail
import org.junit.Test
class StateMachineTests {
sealed interface Events {
data class GoToSecond(val string: String) : Events
object GoToThird : Events
object GoToFourth : Events
object Cancel : Events
}
sealed interface States {
object First : States
data class Second(val string: String) : States
object Third : States
object Fourth : States
object Canceled : States
}
private var enteredSecondState = false
private var exitedFirstState = false
private var transitionHandlerParams: Triple<States, Events, States>? = null
private fun aStateMachine() = createStateMachine<Events, States> {
addInitialState(States.First) {
onExit { exitedFirstState = true }
on<Events.GoToSecond> { first, _ ->
States.Second(first.string)
}
}
addState<States.Second> {
onEnter { enteredSecondState = true }
on<Events.GoToThird>(States.Third)
}
addState<States.Fourth>()
on<Events.GoToFourth, States.Fourth> { _, _ -> States.Fourth }
on<Events.Cancel>(States.Canceled)
}
@Test
fun `process - moves to next state given an event if the route exists`() = aStateMachine().run {
process(Events.GoToSecond("Hello"))
assertThat(currentState).isEqualTo(States.Second("Hello"))
process(Events.GoToThird)
assertThat(currentState).isEqualTo(States.Third)
process(Events.GoToFourth)
assertThat(currentState).isEqualTo(States.Fourth)
}
@Test
fun `process - throws exception if there is no route for an event in a state`() = aStateMachine().run {
runCatching {
process(Events.GoToThird)
}.onSuccess {
fail("It should have thrown an error")
}.onFailure {
assertThat(it.message).startsWith("No route found for state")
}
Unit
}
@Test
fun `process - calls onEnter and onExit callbacks when moving through states`() = aStateMachine().run {
process(Events.GoToSecond("Hello"))
assertThat(currentState).isEqualTo(States.Second("Hello"))
assertThat(exitedFirstState).isTrue()
assertThat(enteredSecondState).isTrue()
}
@Test
fun `process - if an Event route is registered inside a state and outside it, the internal registration takes precedence`() {
val customStateMachine = createStateMachine {
addInitialState(States.First) {
on<Events.Cancel>(States.Canceled)
}
on<Events.Cancel>(States.Fourth)
}
customStateMachine.process(Events.Cancel)
assertThat(customStateMachine.currentState).isEqualTo(States.Canceled)
}
@Test
fun `transitionHandler - is called when moving from a state to another`() = aStateMachine().run {
transitionHandler = { from, event, to ->
transitionHandlerParams = Triple(from, event, to)
}
process(Events.GoToSecond("Hello"))
assertThat(transitionHandlerParams).isEqualTo(
Triple(
States.First,
Events.GoToSecond("Hello"),
States.Second("Hello"),
)
)
}
@Test
fun `restart - sets the state machine to its initial state`() {
val customStateMachine = createStateMachine {
addInitialState(States.First)
on<Events.GoToFourth>(States.Fourth)
}
customStateMachine.process(Events.GoToFourth)
assertThat(customStateMachine.currentState).isEqualTo(States.Fourth)
customStateMachine.restart()
assertThat(customStateMachine.currentState).isEqualTo(customStateMachine.initialState)
}
@Test
fun `init - the state machine must have registered a initial state`() {
runCatching {
createStateMachine<Events, States> {
addState<States.Second>()
on<Events.Cancel>(States.Canceled)
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).isEqualTo("The state machine has no initial state")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for a state throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First)
addState<States.First>()
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration for state ")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for an event inside a state throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First) {
on<Events.GoToThird>(States.Third)
on<Events.GoToThird> { _, _ -> States.Third }
}
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration in state")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for an event at the root level throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First)
on<Events.GoToThird>(States.Third)
on<Events.GoToThird>(States.Third)
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration in state")
}
Unit
}
}

View file

@ -196,6 +196,14 @@ object ElementTextStyles {
textAlign = TextAlign.Start
)
val bodyMD = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
lineHeight = 20.sp,
textAlign = TextAlign.Start
)
val footnote = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Normal,

View file

@ -16,11 +16,15 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun CircularProgressIndicator(
@ -49,3 +53,18 @@ fun CircularProgressIndicator(
strokeWidth = strokeWidth,
)
}
@Composable
fun ButtonCircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onPrimary,
strokeWidth: Dp = 2.dp,
) {
CircularProgressIndicator(
modifier = modifier
.progressSemantics()
.size(18.dp),
color = color,
strokeWidth = strokeWidth,
)
}

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
interface MatrixClient {
val sessionId: SessionId
@ -29,6 +30,7 @@ interface MatrixClient {
fun startSync()
fun stopSync()
fun mediaResolver(): MediaResolver
fun sessionVerificationService(): SessionVerificationService
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String>
@ -38,4 +40,6 @@ interface MatrixClient {
width: Long,
height: Long
): Result<ByteArray>
fun onSlidingSyncUpdate()
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.verification
import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService {
/**
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
*/
val verificationFlowState : StateFlow<VerificationFlowState>
/**
* The internal service that checks verification can only run after the initial sync.
* This [StateFlow] will notify consumers when the service is ready to be used.
*/
val isReady: StateFlow<Boolean>
/**
* Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified]
* or [SessionVerifiedStatus.Verified].
*/
val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus>
/**
* Request verification of the current session.
*/
fun requestVerification()
/**
* Cancels the current verification attempt.
*/
fun cancelVerification()
/**
* Approves the current verification. This must happen on both devices to successfully verify a session.
*/
fun approveVerification()
/**
* Declines the verification attempt because the user could not verify or does not trust the other side of the verification.
*/
fun declineVerification()
/**
* Starts the verification of the unverified session from another device.
*/
fun startVerification()
/**
* Returns the verification service state to the initial step.
*/
fun reset()
}
/** Verification status of the current session. */
sealed interface SessionVerifiedStatus {
/** Unknown status, we couldn't read the actual value from the SDK. */
object Unknown : SessionVerifiedStatus
/** Not verified session status. */
object NotVerified : SessionVerifiedStatus
/** Verified session status. */
object Verified : SessionVerifiedStatus
}
/** States produced by the [SessionVerificationService]. */
sealed interface VerificationFlowState {
/** Initial state. */
object Initial : VerificationFlowState
/** Session verification request was accepted by another device. */
object AcceptedVerificationRequest : VerificationFlowState
/** Short Authentication String (SAS) verification started between the 2 devices. */
object StartedSasVerification : VerificationFlowState
/** Verification data for the SAS verification (emojis) received. */
data class ReceivedVerificationData(val emoji: List<VerificationEmoji>) : VerificationFlowState
/** Verification completed successfully. */
object Finished : VerificationFlowState
/** Verification was cancelled by either device. */
object Canceled : VerificationFlowState
/** Verification failed with an error. */
object Failed : VerificationFlowState
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.verification
data class VerificationEmoji(
val code: String,
val name: String,
)

View file

@ -23,12 +23,17 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
@ -53,6 +58,9 @@ class RustMatrixClient constructor(
override val sessionId: UserId = UserId(client.userId())
private val verificationService = RustSessionVerificationService()
private var slidingSyncUpdateJob: Job? = null
private val clientDelegate = object : ClientDelegate {
override fun didReceiveAuthError(isSoftLogout: Boolean) {
Timber.v("didReceiveAuthError()")
@ -131,6 +139,9 @@ class RustMatrixClient constructor(
client.setDelegate(clientDelegate)
rustRoomSummaryDataSource.init()
slidingSync.setObserver(slidingSyncObserverProxy)
slidingSyncUpdateJob = slidingSyncObserverProxy.updateSummaryFlow
.onEach { onSlidingSyncUpdate() }
.launchIn(coroutineScope)
}
private fun onRestartSync() {
@ -152,6 +163,8 @@ class RustMatrixClient constructor(
override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService
override fun startSync() {
if (client.isSoftLogout()) return
if (isSyncing.compareAndSet(false, true)) {
@ -166,12 +179,14 @@ class RustMatrixClient constructor(
}
private fun close() {
slidingSyncUpdateJob?.cancel()
stopSync()
slidingSync.setObserver(null)
rustRoomSummaryDataSource.close()
client.setDelegate(null)
visibleRoomsView.destroy()
slidingSync.destroy()
verificationService.destroy()
}
override suspend fun logout() = withContext(dispatchers.io) {
@ -226,6 +241,16 @@ class RustMatrixClient constructor(
}
}
override fun onSlidingSyncUpdate() {
if (!verificationService.isReady.value) {
try {
verificationService.verificationController = client.getSessionVerificationController()
} catch (e: Throwable) {
Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.")
}
}
}
private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@Module
@ContributesTo(SessionScope::class)
object SessionMatrixModule {
@Provides
@SingleIn(SessionScope::class)
fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
return matrixClient.sessionVerificationService()
}
}

View file

@ -36,7 +36,6 @@ class SlidingSyncObserverProxy(
val updateSummaryFlow: SharedFlow<UpdateSummary> = updateSummaryMutableFlow.asSharedFlow()
override fun didReceiveSyncUpdate(summary: UpdateSummary) {
if (summary.rooms.isEmpty()) return
coroutineScope.launch {
updateSummaryMutableFlow.emit(summary)
}

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
import org.matrix.rustcomponents.sdk.SessionVerificationEmoji
import javax.inject.Inject
class RustSessionVerificationService @Inject constructor() : SessionVerificationService, SessionVerificationControllerDelegate {
var verificationController: SessionVerificationControllerInterface? = null
set(value) {
field = value
_isReady.value = value != null
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
if (value != null) {
updateVerificationStatus(value.isVerified())
}
}
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow()
private val _isReady = MutableStateFlow(false)
override val isReady = _isReady.asStateFlow()
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override fun requestVerification() = tryOrFail {
verificationController?.setDelegate(this)
verificationController?.requestVerification()
}
override fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() }
override fun approveVerification() = tryOrFail { verificationController?.approveVerification() }
override fun declineVerification() = tryOrFail { verificationController?.declineVerification() }
override fun startVerification() = tryOrFail {
verificationController?.setDelegate(this)
verificationController?.startSasVerification()
}
private fun tryOrFail(block: () -> Unit) {
runCatching {
block()
}.onFailure { didFail() }
}
// region Delegate implementation
// When verification attempt is accepted by the other device
override fun didAcceptVerificationRequest() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
}
override fun didCancel() {
_verificationFlowState.value = VerificationFlowState.Canceled
}
override fun didFail() {
_verificationFlowState.value = VerificationFlowState.Failed
}
override fun didFinish() {
_verificationFlowState.value = VerificationFlowState.Finished
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
updateVerificationStatus(isVerified = true)
}
override fun didReceiveVerificationData(data: List<SessionVerificationEmoji>) {
val emojis = data.map { emoji ->
emoji.use { VerificationEmoji(it.symbol(), it.description()) }
}
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojis)
}
// When the actual SAS verification starts
override fun didStartSasVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
}
// end-region
override fun reset() {
if (isReady.value) {
// Cancel any pending verification attempt
tryOrNull { verificationController?.cancelVerification() }
}
_verificationFlowState.value = VerificationFlowState.Initial
}
fun destroy() {
(verificationController as? SessionVerificationController)?.destroy()
verificationController = null
}
private fun updateVerificationStatus(isVerified: Boolean) {
val newValue = when {
!isReady.value -> SessionVerifiedStatus.Unknown
!isVerified -> SessionVerifiedStatus.NotVerified
else -> SessionVerifiedStatus.Verified
}
_sessionVerifiedStatus.value = newValue
}
}

View file

@ -22,16 +22,19 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.delay
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource()
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService()
) : MatrixClient {
private var logoutFailure: Throwable? = null
@ -72,4 +75,8 @@ class FakeMatrixClient(
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
return Result.success(ByteArray(0))
}
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun onSlidingSyncUpdate() {}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.verification
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService : SessionVerificationService {
private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var emojiList = emptyList<VerificationEmoji>()
var shouldFail = false
override val verificationFlowState: StateFlow<VerificationFlowState>
get() = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val isReady: StateFlow<Boolean> = _isReady
override fun requestVerification() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
}
override fun cancelVerification() {
_verificationFlowState.value = VerificationFlowState.Canceled
}
override fun approveVerification() {
if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Finished
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
}
override fun declineVerification() {
if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Canceled
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
}
override fun startVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
}
fun givenVerifiedStatus(status: SessionVerifiedStatus) {
_sessionVerifiedStatus.value = status
}
fun givenVerificationFlowState(state: VerificationFlowState) {
_verificationFlowState.value = state
}
fun givenIsReady(value: Boolean) {
_isReady.value = value
}
fun givenEmojiList(emojis: List<VerificationEmoji>) {
this.emojiList = emojis
}
override fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial
}
}

View file

@ -19,4 +19,29 @@
<string name="search_for_someone">Search for someone</string>
<string name="new_room">New room</string>
<string name="verification_title_initial">Open an existing session</string>
<string name="verification_title_waiting">Waiting to accept request</string>
<string name="verification_title_canceled">Verification cancelled</string>
<string name="verification_title_verifying">Compare emojis</string>
<string name="verification_subtitle_initial">Prove it\'s you in order to access your encrypted message history.</string>
<string name="verification_subtitle_waiting">Accept the request to start the verification process in your other session to continue.</string>
<string name="verification_subtitle_canceled">Something doesn\'t seem right. Either the request timed out or the request was denied.</string>
<string name="verification_subtitle_verifying">Confirm that the emojis below match those shown on your other session.</string>
<string name="verification_positive_button_initial">I am ready</string>
<string name="verification_positive_button_canceled">Retry verification</string>
<string name="verification_positive_button_verifying_start">They match</string>
<string name="verification_positive_button_verifying_ongoing">Waiting to match</string>
<string name="verification_negative_button_initial">@string/action_cancel</string>
<string name="verification_negative_button_canceled">@string/action_cancel</string>
<string name="verification_negative_button_verifying">They don\'t match</string>
<string name="session_verification_banner_title">Access your message history</string>
<string name="session_verification_banner_message">Looks like you\'re using a new device. Verify it\'s you to access your encrypted messages.</string>
<string name="session_verification_start">Continue</string>
<string name="verification_conclusion_ok_self_notice_title">Verification complete</string>
</resources>