Add Session Verification flow (#197)
This commit is contained in:
parent
1795a844a1
commit
dcb98f06aa
76 changed files with 2347 additions and 35 deletions
|
|
@ -29,4 +29,7 @@ java {
|
|||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(":", "_")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue