Merge pull request #566 from vector-im/feature/fga/update-rust-sdk-0.1.16

Feature/fga/update rust sdk 0.1.16
This commit is contained in:
ganfra 2023-06-09 17:26:11 +02:00 committed by GitHub
commit 53f234d135
16 changed files with 251 additions and 666 deletions

View file

@ -39,7 +39,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.statemachine) api(libs.statemachine)
api(projects.features.verifysession.api) api(projects.features.verifysession.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -14,24 +14,31 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import com.freeletics.flowredux.compose.rememberStateAndDispatch
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @Inject constructor( class VerifySelfSessionPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val stateMachine: VerifySelfSessionStateMachine,
) : Presenter<VerifySelfSessionState> { ) : Presenter<VerifySelfSessionState> {
@Composable @Composable
@ -40,48 +47,36 @@ class VerifySelfSessionPresenter @Inject constructor(
// Force reset, just in case the service was left in a broken state // Force reset, just in case the service was left in a broken state
sessionVerificationService.reset() sessionVerificationService.reset()
} }
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val coroutineScope = rememberCoroutineScope() val verificationFlowStep by remember {
val stateMachine = remember { VerifySelfSessionStateMachine(coroutineScope, sessionVerificationService) } derivedStateOf { stateAndDispatch.state.value.toVerificationStep() }
}
// Create the new view state from the StateMachine state // Start this after observing state machine
val stateMachineCurrentState by stateMachine.state.collectAsState() LaunchedEffect(Unit) {
val verificationFlowState by remember { observeVerificationService()
derivedStateOf { stateMachineStateToViewState(stateMachineCurrentState) }
} }
fun handleEvents(event: VerifySelfSessionViewEvents) { fun handleEvents(event: VerifySelfSessionViewEvents) {
when (event) { when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateMachine.process(StateMachineEvent.RequestVerification) VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateMachine.process(StateMachineEvent.StartSasVerification) VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.Restart -> stateMachine.process(StateMachineEvent.Restart) VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart)
VerifySelfSessionViewEvents.ConfirmVerification -> stateMachine.process(StateMachineEvent.AcceptChallenge) VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateMachine.process(StateMachineEvent.DeclineChallenge) VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.CancelAndClose -> { VerifySelfSessionViewEvents.CancelAndClose -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
if (stateMachineCurrentState !in sequenceOf(
StateMachineState.Initial,
StateMachineState.Completed,
StateMachineState.Canceled
)
) {
stateMachine.process(StateMachineEvent.Cancel)
}
}
} }
} }
return VerifySelfSessionState( return VerifySelfSessionState(
verificationFlowStep = verificationFlowState, verificationFlowStep = verificationFlowStep,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun stateMachineStateToViewState(state: StateMachineState): VerifySelfSessionState.VerificationStep = private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.VerificationStep =
when (state) { when (val machineState = this) {
StateMachineState.Initial -> { StateMachineState.Initial, null -> {
VerifySelfSessionState.VerificationStep.Initial VerifySelfSessionState.VerificationStep.Initial
} }
StateMachineState.RequestingVerification, StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification, StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted, StateMachineState.SasVerificationStarted,
@ -98,15 +93,41 @@ class VerifySelfSessionPresenter @Inject constructor(
} }
is StateMachineState.Verifying -> { is StateMachineState.Verifying -> {
val async = when (state) { val async = when (machineState) {
is StateMachineState.Verifying.Replying -> Async.Loading() is StateMachineState.Verifying.Replying -> Async.Loading()
else -> Async.Uninitialized else -> Async.Uninitialized
} }
VerifySelfSessionState.VerificationStep.Verifying(state.emojis, async) VerifySelfSessionState.VerificationStep.Verifying(machineState.emojis, async)
} }
StateMachineState.Completed -> { StateMachineState.Completed -> {
VerifySelfSessionState.VerificationStep.Completed VerifySelfSessionState.VerificationStep.Completed
} }
} }
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart)
VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
}
VerificationFlowState.StartedSasVerification -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
}
is VerificationFlowState.ReceivedVerificationData -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.emoji))
}
VerificationFlowState.Finished -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
}
VerificationFlowState.Canceled -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
}
VerificationFlowState.Failed -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
}
}
}.launchIn(this)
}
} }

View file

@ -15,102 +15,114 @@
*/ */
@file:Suppress("WildcardImport") @file:Suppress("WildcardImport")
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl
import io.element.android.libraries.statemachine.createStateMachine import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CoroutineScope import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow import com.freeletics.flowredux.dsl.State as MachineState
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class VerifySelfSessionStateMachine( class VerifySelfSessionStateMachine @Inject constructor(
coroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
initialState = State.Initial
) { ) {
private val stateMachine = createStateMachine { init {
addInitialState(State.Initial) { spec {
on<Event.RequestVerification>(State.RequestingVerification) inState<State.Initial> {
on<Event.StartSasVerification>(State.StartingSasVerification) on { _: Event.RequestVerification, state: MachineState<State.Initial> ->
} state.override { State.RequestingVerification }
addState<State.RequestingVerification> { }
onEnter { sessionVerificationService.requestVerification() } on { _: Event.StartSasVerification, state: MachineState<State.Initial> ->
state.override { State.StartingSasVerification }
on<Event.DidAcceptVerificationRequest>(State.VerificationRequestAccepted)
on<Event.DidFail>(State.Initial)
}
addState<State.StartingSasVerification> {
onEnter { sessionVerificationService.startVerification() }
}
addState<State.VerificationRequestAccepted> {
on<Event.StartSasVerification>(State.StartingSasVerification)
}
addState<State.Canceled> {
on<Event.Restart>(State.RequestingVerification)
}
addState<State.SasVerificationStarted> {
on<Event.DidReceiveChallenge> { event, _ -> State.Verifying.ChallengeReceived(event.emojis) }
}
addState<State.Verifying.ChallengeReceived> {
on<Event.AcceptChallenge> { _, prevState -> State.Verifying.Replying(prevState.emojis, true) }
on<Event.DeclineChallenge> { _, prevState -> State.Verifying.Replying(prevState.emojis, false) }
}
addState<State.Verifying.Replying> {
onEnter { state ->
if (state.accept) {
sessionVerificationService.approveVerification()
} else {
sessionVerificationService.declineVerification()
} }
} }
on<Event.DidAcceptChallenge>(State.Completed) inState<State.RequestingVerification> {
} onEnterEffect {
addState<State.Canceling> { sessionVerificationService.requestVerification()
onEnter { sessionVerificationService.cancelVerification() }
}
on<Event.DidStartSasVerification>(State.SasVerificationStarted)
on<Event.Cancel>(State.Canceling)
on<Event.DidCancel>(State.Canceled)
on<Event.DidFail>(State.Canceled)
}
init {
// Observe the verification service state, translate it to state machine input events
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.Initial -> stateMachine.restart()
VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.process(Event.DidAcceptVerificationRequest)
} }
VerificationFlowState.StartedSasVerification -> { on { _: Event.DidAcceptVerificationRequest, state: MachineState<State.RequestingVerification> ->
stateMachine.process(Event.DidStartSasVerification) state.override { State.VerificationRequestAccepted }
} }
is VerificationFlowState.ReceivedVerificationData -> { on { _: Event.DidFail, state: MachineState<State.RequestingVerification> ->
// For some reason we receive this state twice, we need to discard the 2nd one state.override { State.Initial }
if (stateMachine.currentState == State.SasVerificationStarted) { }
stateMachine.process(Event.DidReceiveChallenge(verificationAttemptState.emoji)) }
inState<State.StartingSasVerification> {
onEnterEffect {
sessionVerificationService.startVerification()
}
}
inState<State.VerificationRequestAccepted> {
on { _: Event.StartSasVerification, state: MachineState<State.VerificationRequestAccepted> ->
state.override { State.StartingSasVerification }
}
}
inState<State.Canceled> {
on { _: Event.Restart, state: MachineState<State.Canceled> ->
state.override { State.RequestingVerification }
}
}
inState<State.SasVerificationStarted> {
on { event: Event.DidReceiveChallenge, state: MachineState<State.SasVerificationStarted> ->
state.override { State.Verifying.ChallengeReceived(event.emojis) }
}
}
inState<State.Verifying.ChallengeReceived> {
on { _: Event.AcceptChallenge, state: MachineState<State.Verifying.ChallengeReceived> ->
state.override { State.Verifying.Replying(state.snapshot.emojis, accept = true) }
}
on { _: Event.DeclineChallenge, state: MachineState<State.Verifying.ChallengeReceived> ->
state.override { State.Verifying.Replying(state.snapshot.emojis, accept = false) }
}
}
inState<State.Verifying.Replying> {
onEnterEffect { state ->
if (state.accept) {
sessionVerificationService.approveVerification()
} else {
sessionVerificationService.declineVerification()
} }
} }
VerificationFlowState.Finished -> { on { _: Event.DidAcceptChallenge, state: MachineState<State.Verifying.Replying> ->
stateMachine.process(Event.DidAcceptChallenge) state.override { State.Completed }
}
VerificationFlowState.Canceled -> {
stateMachine.process(Event.DidCancel)
}
VerificationFlowState.Failed -> {
stateMachine.process(Event.DidFail)
} }
} }
}.launchIn(coroutineScope) inState<State.Canceling> {
onEnterEffect {
sessionVerificationService.cancelVerification()
}
}
inState {
on { _: Event.DidStartSasVerification, state: MachineState<State> ->
state.override { State.SasVerificationStarted }
}
on { _: Event.Cancel, state: MachineState<State> ->
if (state.snapshot in sequenceOf(
State.Initial,
State.Completed,
State.Canceled
)) {
state.noChange()
} else {
state.override { State.Canceling }
}
}
on { _: Event.DidCancel, state: MachineState<State> ->
state.override { State.Canceled }
}
on { _: Event.DidFail, state: MachineState<State> ->
state.override { State.Canceled }
}
}
}
} }
val state: StateFlow<State> = stateMachine.stateFlow
fun process(event: Event) = stateMachine.process(event)
sealed interface State { sealed interface State {
/** The initial state, before verification started. */ /** The initial state, before verification started. */
object Initial : State object Initial : State
@ -134,10 +146,13 @@ class VerifySelfSessionStateMachine(
/** Replying to a verification challenge. */ /** Replying to a verification challenge. */
data class Replying(override val emojis: List<VerificationEmoji>, val accept: Boolean) : Verifying(emojis) data class Replying(override val emojis: List<VerificationEmoji>, val accept: Boolean) : Verifying(emojis)
} }
/** The verification is being canceled. */ /** The verification is being canceled. */
object Canceling : State object Canceling : State
/** The verification has been canceled, remotely or locally. */ /** The verification has been canceled, remotely or locally. */
object Canceled : State object Canceled : State
/** Verification successful. */ /** Verification successful. */
object Completed : State object Completed : State
} }
@ -145,26 +160,37 @@ class VerifySelfSessionStateMachine(
sealed interface Event { sealed interface Event {
/** Request verification. */ /** Request verification. */
object RequestVerification : Event object RequestVerification : Event
/** The current verification request has been accepted. */ /** The current verification request has been accepted. */
object DidAcceptVerificationRequest : Event object DidAcceptVerificationRequest : Event
/** Start a SaS verification flow. */ /** Start a SaS verification flow. */
object StartSasVerification : Event object StartSasVerification : Event
/** Started a SaS verification flow. */ /** Started a SaS verification flow. */
object DidStartSasVerification : Event object DidStartSasVerification : Event
/** Has received emojis. */ /** Has received emojis. */
data class DidReceiveChallenge(val emojis: List<VerificationEmoji>) : Event data class DidReceiveChallenge(val emojis: List<VerificationEmoji>) : Event
/** Emojis match. */ /** Emojis match. */
object AcceptChallenge : Event object AcceptChallenge : Event
/** Emojis do not match. */ /** Emojis do not match. */
object DeclineChallenge : Event object DeclineChallenge : Event
/** Remote accepted challenge. */ /** Remote accepted challenge. */
object DidAcceptChallenge : Event object DidAcceptChallenge : Event
/** Request cancellation. */ /** Request cancellation. */
object Cancel : Event object Cancel : Event
/** Verification cancelled. */ /** Verification cancelled. */
object DidCancel : Event object DidCancel : Event
/** Request failed. */ /** Request failed. */
object DidFail : Event object DidFail : Event
/** Restart the verification flow. */ /** Restart the verification flow. */
object Restart : Event object Restart : Event
} }

View file

@ -18,14 +18,13 @@ package io.element.android.features.verifysession.impl
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as VerificationStep import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -36,8 +35,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Initial state is received`() = runTest { fun `present - Initial state is received`() = runTest {
val service = FakeSessionVerificationService() val presenter = createPresenter()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -48,30 +46,18 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Handles requestVerification`() = runTest { fun `present - Handles requestVerification`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// Await for the state to be Ready
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Ready)
// Await for other device response (again):
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// Finally, ChallengeReceived:
val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
} }
} }
@Test @Test
fun `present - Handles startSasVerification`() = runTest { fun `present - Handles startSasVerification`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -82,6 +68,7 @@ class VerifySelfSessionPresenterTests {
// Await for other device response: // Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// ChallengeReceived: // ChallengeReceived:
service.triggerReceiveVerificationData()
val verifyingState = awaitItem() val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
} }
@ -89,8 +76,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Cancelation on initial state does nothing`() = runTest { fun `present - Cancelation on initial state does nothing`() = runTest {
val service = FakeSessionVerificationService() val presenter = createPresenter()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -105,65 +91,43 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - A fail in the flow cancels it`() = runTest { fun `present - A fail in the flow cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val state = requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
service.shouldFail = true service.shouldFail = true
eventSink(VerifySelfSessionViewEvents.ConfirmVerification) state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
// Cancelling
val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item<VerifySelfSessionState>)?.value } assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled) // Cancelled
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
} }
} }
@Test @Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest { fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val state = requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
val eventSink = initialState.eventSink assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
eventSink(VerifySelfSessionViewEvents.RequestVerification) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
eventSink(VerifySelfSessionViewEvents.CancelAndClose)
val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item<VerifySelfSessionState>)?.value }
assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
} }
} }
@Test @Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(emptyList())) service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(emptyList()))
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
} }
@ -171,21 +135,14 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest { fun `present - Restart after cancelation returns to requesting verification`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val state = requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized))
service.givenVerificationFlowState(VerificationFlowState.Canceled) service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Restart)
eventSink(VerifySelfSessionViewEvents.Restart)
// Went back to requesting verification // Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -200,18 +157,12 @@ class VerifySelfSessionPresenterTests {
val service = FakeSessionVerificationService().apply { val service = FakeSessionVerificationService().apply {
givenEmojiList(emojis) givenEmojiList(emojis)
} }
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val state = requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Uninitialized))
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Loading())) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Loading()))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
} }
@ -220,28 +171,41 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - When verification is declined, the flow is canceled`() = runTest { fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = FakeSessionVerificationService() val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service) val presenter = createPresenter(service)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val state = requestVerificationAndAwaitVerifyingState(service)
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized))
eventSink(VerifySelfSessionViewEvents.DeclineVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Loading())) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Loading()))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
} }
} }
private suspend fun ReceiveTurbine<VerifySelfSessionState>.awaitChallengeReceivedState(): VerifySelfSessionState { private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState(
// Skip 'waiting for response', 'ready' and 'starting verification' state fakeService: FakeSessionVerificationService
skipItems(3) ): VerifySelfSessionState {
// Received challenge var state = awaitItem()
return awaitItem() assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// Await for the state to be Ready
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response (again):
state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
fakeService.triggerReceiveVerificationData()
// Finally, ChallengeReceived:
state = awaitItem()
assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
return state
}
private fun createPresenter(service: FakeSessionVerificationService = FakeSessionVerificationService()): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(service, VerifySelfSessionStateMachine(service))
} }
} }

View file

@ -139,7 +139,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.15" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.16"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@ -151,6 +151,7 @@ otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.1.0"
# Analytics # Analytics
posthog = "com.posthog.android:posthog:2.0.3" posthog = "com.posthog.android:posthog:2.0.3"

View file

@ -40,32 +40,32 @@ interface SessionVerificationService {
/** /**
* Request verification of the current session. * Request verification of the current session.
*/ */
fun requestVerification() suspend fun requestVerification()
/** /**
* Cancels the current verification attempt. * Cancels the current verification attempt.
*/ */
fun cancelVerification() suspend fun cancelVerification()
/** /**
* Approves the current verification. This must happen on both devices to successfully verify a session. * Approves the current verification. This must happen on both devices to successfully verify a session.
*/ */
fun approveVerification() suspend fun approveVerification()
/** /**
* Declines the verification attempt because the user could not verify or does not trust the other side of the verification. * Declines the verification attempt because the user could not verify or does not trust the other side of the verification.
*/ */
fun declineVerification() suspend fun declineVerification()
/** /**
* Starts the verification of the unverified session from another device. * Starts the verification of the unverified session from another device.
*/ */
fun startVerification() suspend fun startVerification()
/** /**
* Returns the verification service state to the initial step. * Returns the verification service state to the initial step.
*/ */
fun reset() suspend fun reset()
} }
/** Verification status of the current session. */ /** Verification status of the current session. */

View file

@ -64,6 +64,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
import org.matrix.rustcomponents.sdk.SlidingSyncSelectiveModeBuilder
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
@ -124,8 +125,7 @@ class RustMatrixClient constructor(
) )
) )
.filters(visibleRoomsSlidingSyncFilters) .filters(visibleRoomsSlidingSyncFilters)
.syncModeSelective() .syncModeSelective(SlidingSyncSelectiveModeBuilder().addRange(0u, 20u))
.addRange(0u, 20u)
.onceBuilt(object : SlidingSyncListOnceBuilt { .onceBuilt(object : SlidingSyncListOnceBuilt {
override fun updateList(list: SlidingSyncList): SlidingSyncList { override fun updateList(list: SlidingSyncList): SlidingSyncList {
visibleRoomsSlidingSyncList.tryEmit(list) visibleRoomsSlidingSyncList.tryEmit(list)
@ -146,8 +146,7 @@ class RustMatrixClient constructor(
) )
) )
.filters(invitesSlidingSyncFilters) .filters(invitesSlidingSyncFilters)
.syncModeSelective() .syncModeSelective(SlidingSyncSelectiveModeBuilder().addRange(0u, 20u))
.addRange(0u, 20u)
.onceBuilt(object : SlidingSyncListOnceBuilt { .onceBuilt(object : SlidingSyncListOnceBuilt {
override fun updateList(list: SlidingSyncList): SlidingSyncList { override fun updateList(list: SlidingSyncList): SlidingSyncList {
invitesSlidingSyncList.tryEmit(list) invitesSlidingSyncList.tryEmit(list)
@ -156,10 +155,9 @@ class RustMatrixClient constructor(
}) })
private val slidingSync = client private val slidingSync = client
.slidingSync() .slidingSync("ElementX")
// .homeserver("https://slidingsync.lab.matrix.org") // .homeserver("https://slidingsync.lab.matrix.org")
.withCommonExtensions() .withCommonExtensions()
.storageKey("ElementX")
.addList(visibleRoomsSlidingSyncListBuilder) .addList(visibleRoomsSlidingSyncListBuilder)
.addList(invitesSlidingSyncListBuilder) .addList(invitesSlidingSyncListBuilder)
.use { .use {

View file

@ -75,8 +75,7 @@ class RustMediaLoader(
mediaSource = mediaSource, mediaSource = mediaSource,
body = body, body = body,
mimeType = mimeType ?: "application/octet-stream", mimeType = mimeType ?: "application/octet-stream",
//TODO uncomment when rust api will be merged tempDir = cacheDirectory.path,
//tempDir = cacheDirectory.path,
) )
RustMediaFile(mediaFile) RustMediaFile(mediaFile)
} }

View file

@ -38,6 +38,7 @@ import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.SlidingSync import org.matrix.rustcomponents.sdk.SlidingSync
import org.matrix.rustcomponents.sdk.SlidingSyncList import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff
import org.matrix.rustcomponents.sdk.SlidingSyncSelectiveModeBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncState import org.matrix.rustcomponents.sdk.SlidingSyncState
import org.matrix.rustcomponents.sdk.UpdateSummary import org.matrix.rustcomponents.sdk.UpdateSummary
import timber.log.Timber import timber.log.Timber
@ -98,7 +99,9 @@ internal class RustRoomSummaryDataSource(
override fun setSlidingSyncRange(range: IntRange) { override fun setSlidingSyncRange(range: IntRange) {
Timber.v("setVisibleRange=$range") Timber.v("setVisibleRange=$range")
coroutineScope.launch { coroutineScope.launch {
slidingSyncListFlow.first().setRange(range.first.toUInt(), range.last.toUInt()) val slidingSyncMode = SlidingSyncSelectiveModeBuilder()
.addRange(range.first.toUInt(), range.last.toUInt())
slidingSyncListFlow.first().setSyncMode(slidingSyncMode)
} }
} }

View file

@ -159,7 +159,7 @@ class RustMatrixTimeline(
), ),
timelineLimit = null timelineLimit = null
) )
listenerTokens += slidingSyncRoom.subscribeToRoom(settings) slidingSyncRoom.subscribeToRoom(settings)
val result = slidingSyncRoom.addTimelineListener(timelineListener) val result = slidingSyncRoom.addTimelineListener(timelineListener)
launch { launch {
fetchMembers() fetchMembers()

View file

@ -18,9 +18,9 @@ package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -52,21 +52,21 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow() override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override fun requestVerification() = tryOrFail { override suspend fun requestVerification() = tryOrFail {
verificationController?.requestVerification() verificationController?.requestVerification()
} }
override fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() } override suspend fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() }
override fun approveVerification() = tryOrFail { verificationController?.approveVerification() } override suspend fun approveVerification() = tryOrFail { verificationController?.approveVerification() }
override fun declineVerification() = tryOrFail { verificationController?.declineVerification() } override suspend fun declineVerification() = tryOrFail { verificationController?.declineVerification() }
override fun startVerification() = tryOrFail { override suspend fun startVerification() = tryOrFail {
verificationController?.startSasVerification() verificationController?.startSasVerification()
} }
private fun tryOrFail(block: () -> Unit) { private suspend fun tryOrFail(block: suspend () -> Unit) {
runCatching { runCatching {
block() block()
}.onFailure { didFail() } }.onFailure { didFail() }
@ -107,7 +107,7 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
// end-region // end-region
override fun reset() { override suspend fun reset() {
if (isReady.value) { if (isReady.value) {
// Cancel any pending verification attempt // Cancel any pending verification attempt
tryOrNull { verificationController?.cancelVerification() } tryOrNull { verificationController?.cancelVerification() }

View file

@ -37,17 +37,15 @@ class FakeSessionVerificationService : SessionVerificationService {
override val isReady: StateFlow<Boolean> = _isReady override val isReady: StateFlow<Boolean> = _isReady
override fun requestVerification() { override suspend fun requestVerification() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
} }
override fun cancelVerification() { override suspend fun cancelVerification() {
_verificationFlowState.value = VerificationFlowState.Canceled _verificationFlowState.value = VerificationFlowState.Canceled
} }
override fun approveVerification() { override suspend fun approveVerification() {
if (!shouldFail) { if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Finished _verificationFlowState.value = VerificationFlowState.Finished
} else { } else {
@ -55,7 +53,7 @@ class FakeSessionVerificationService : SessionVerificationService {
} }
} }
override fun declineVerification() { override suspend fun declineVerification() {
if (!shouldFail) { if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Canceled _verificationFlowState.value = VerificationFlowState.Canceled
} else { } else {
@ -63,11 +61,14 @@ class FakeSessionVerificationService : SessionVerificationService {
} }
} }
override fun startVerification() { fun triggerReceiveVerificationData() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList) _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
} }
override suspend fun startVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
}
fun givenVerifiedStatus(status: SessionVerifiedStatus) { fun givenVerifiedStatus(status: SessionVerifiedStatus) {
_sessionVerifiedStatus.value = status _sessionVerifiedStatus.value = status
} }
@ -84,7 +85,7 @@ class FakeSessionVerificationService : SessionVerificationService {
this.emojiList = emojis this.emojiList = emojis
} }
override fun reset() { override suspend fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial _verificationFlowState.value = VerificationFlowState.Initial
} }
} }

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("java-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
implementation(libs.coroutines.core)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}

View file

@ -1,192 +0,0 @@
/*
* 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.statemachine
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)
}
@Suppress("UNCHECKED_CAST")
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) == true }
?: 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

@ -1,202 +0,0 @@
/*
* 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.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

@ -94,7 +94,6 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:dateformatter:impl")) implementation(project(":libraries:dateformatter:impl"))
implementation(project(":libraries:di")) implementation(project(":libraries:di"))
implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:session-storage:impl"))
implementation(project(":libraries:statemachine"))
implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:usersearch:impl"))