Add Session Verification flow (#197)
This commit is contained in:
committed by
GitHub
parent
18c45ad620
commit
9639d62bb3
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user