Skip to content

Commit 59ffddf

Browse files
feat: introduce state machine class (#10170)
2 parents 5ff7bf6 + 703e021 commit 59ffddf

File tree

6 files changed

+1342
-0
lines changed

6 files changed

+1342
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package net.thunderbird.core.common.state
2+
3+
import kotlin.reflect.KClass
4+
import kotlin.time.Duration.Companion.milliseconds
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.delay
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.StateFlow
9+
import kotlinx.coroutines.flow.asStateFlow
10+
import kotlinx.coroutines.flow.update
11+
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.sync.Mutex
13+
import kotlinx.coroutines.sync.withLock
14+
15+
internal typealias TransactionKey<TState, TEvent> = Pair<KClass<out TState>, KClass<out TEvent>>
16+
17+
/**
18+
* Defines the core contract for a generic, thread-safe state machine.
19+
*
20+
* This interface provides a standardized way to manage state transitions in response to events.
21+
* It is designed to be asynchronous, using Kotlin Coroutines and [StateFlow] to expose the
22+
* current state to observers. The state machine guarantees that event processing and state
23+
* updates are atomic operations.
24+
*
25+
* Implementations of this interface allow for defining a graph of states and the transitions
26+
* between them, which are triggered by specific events.
27+
*
28+
* @param TState The base sealed interface for all possible states.
29+
* @param TEvent The base sealed interface for all possible events.
30+
*/
31+
interface StateMachine<TState : Any, TEvent : Any> {
32+
val currentState: StateFlow<TState>
33+
val currentStateSnapshot: TState get() = currentState.value
34+
35+
/**
36+
* Processes an incoming event against the current state.
37+
*
38+
* This function looks for a defined transition that matches the current state's type and the
39+
* provided event's type. If a matching transition is found and its `guard` condition passes,
40+
* the state machine will transition to a new state. The new state is then emitted to observers
41+
* of the [currentState] flow.
42+
*
43+
* If no transition is defined for the state/event pair, or if the `guard` condition
44+
* returns `false`, the event is ignored, and the state remains unchanged.
45+
*
46+
* This operation is thread-safe, ensuring that state transitions are processed atomically.
47+
*
48+
* @param event The event to process.
49+
* @return The new state if a transition occurred, or the current state if no transition was made.
50+
*/
51+
suspend fun process(event: TEvent): TState
52+
}
53+
54+
internal class DefaultStateMachine<TState : Any, TEvent : Any>(
55+
scope: CoroutineScope,
56+
initialState: TState,
57+
internal val stateRegistrar: Map<KClass<out TState>, StateRegistry<TState, TState, TEvent>>,
58+
private val transitions: Map<TransactionKey<out TState, out TEvent>, Transition<TState, TEvent>>,
59+
) : StateMachine<TState, TEvent> {
60+
data class StateRegistry<TCurrentState : TState, TState : Any, TEvent : Any>(
61+
val stateClass: KClass<out TState>,
62+
val isFinalState: Boolean,
63+
val listeners: StateListeners<TCurrentState, TState, TEvent>,
64+
val transitions: Map<TransactionKey<out TState, out TEvent>, Transition<TState, TEvent>>,
65+
)
66+
67+
data class Transition<TState : Any, in TEvent : Any>(
68+
val guard: (TState, TEvent) -> Boolean,
69+
val createNewState: (TState, TEvent) -> TState,
70+
)
71+
72+
data class StateListeners<in TCurrentState : TState, TState : Any, in TEvent : Any>(
73+
val onEnter: (TState?.(event: TEvent?, newState: TCurrentState) -> Unit)?,
74+
val onExit: (TCurrentState.(event: TEvent?) -> Unit)?,
75+
)
76+
77+
private val mutex = Mutex()
78+
private val _currentState = MutableStateFlow(initialState)
79+
override val currentState: StateFlow<TState> = _currentState.asStateFlow()
80+
81+
init {
82+
scope.launch {
83+
// delay onEnter initialization so the viewModels are ready to receive the state
84+
println("Executed init. $stateRegistrar")
85+
delay(500.milliseconds)
86+
println("Executed? $stateRegistrar")
87+
stateRegistrar[initialState::class]?.listeners?.onEnter?.invoke(null, null, currentStateSnapshot)
88+
}
89+
}
90+
91+
override suspend fun process(event: TEvent): TState {
92+
mutex.withLock {
93+
val currentStateRegistry = stateRegistrar[_currentState.value::class]
94+
if (currentStateRegistry?.isFinalState == true) return currentState.value
95+
96+
val key = _currentState.value::class to event::class
97+
val transition = transitions[key]
98+
?: transitions
99+
.filterKeys { (stateClass, eventClass) ->
100+
stateClass.isInstance(_currentState.value) && eventClass == event::class
101+
}
102+
.firstNotNullOfOrNull { it.value }
103+
104+
return when {
105+
transition != null && transition.guard(_currentState.value, event) -> {
106+
transition.createNewState(_currentState.value, event).also { newState ->
107+
_currentState.update { currentState ->
108+
val newStateRegistry = stateRegistrar[newState::class]
109+
when {
110+
newStateRegistry?.isFinalState == true -> {
111+
currentStateRegistry?.listeners?.onExit?.invoke(currentState, event)
112+
newStateRegistry.listeners.onEnter?.invoke(currentState, event, newState)
113+
newStateRegistry.listeners.onExit?.invoke(newState, null)
114+
}
115+
116+
currentState::class != newState::class -> {
117+
currentStateRegistry?.listeners?.onExit?.invoke(currentState, event)
118+
newStateRegistry?.listeners?.onEnter?.invoke(currentState, event, newState)
119+
}
120+
}
121+
122+
newState
123+
}
124+
}
125+
}
126+
127+
else -> {
128+
// No transition defined or guard failed -> Ignore event
129+
_currentState.value
130+
}
131+
}
132+
}
133+
}
134+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package net.thunderbird.core.common.state.builder
2+
3+
import kotlin.contracts.ExperimentalContracts
4+
import kotlin.reflect.KClass
5+
import net.thunderbird.core.common.state.DefaultStateMachine.StateListeners
6+
import net.thunderbird.core.common.state.DefaultStateMachine.StateRegistry
7+
import net.thunderbird.core.common.state.DefaultStateMachine.Transition
8+
import net.thunderbird.core.common.state.StateMachine
9+
import net.thunderbird.core.common.state.TransactionKey
10+
11+
/**
12+
* A builder for a state in a [StateMachine] that does not have any transitions defined yet.
13+
* This is the initial builder returned when defining a new state.
14+
*
15+
* @param TCurrentState The specific type of the state being configured.
16+
* @param TState The base type for all states in the state machine.
17+
* @param TEvent The base type for all events that can trigger transitions.
18+
*/
19+
interface StateWithoutTransactionsBuilder<TCurrentState : TState, TState : Any, TEvent : Any> {
20+
/**
21+
* The [KClass] representing the specific state being configured by this builder.
22+
* This is used to identify the state within the state machine.
23+
*/
24+
val stateClass: KClass<out TCurrentState>
25+
26+
/**
27+
* A flag indicating whether this state is a final state in the state machine.
28+
*
29+
* When a state machine enters a final state, it is considered "finished" and will no longer
30+
* process any new events. Defaults to `false`.
31+
*/
32+
var isFinalState: Boolean
33+
34+
/**
35+
* Defines an action to be executed when the state machine enters this state.
36+
*
37+
* This action is triggered immediately after the transition to this state is complete, but
38+
* before any subsequent events are processed. It is useful for performing setup tasks,
39+
* logging, or triggering side effects specific to this state.
40+
*
41+
* The provided [block] is a lambda with a receiver of the *old state* ([TState]`?`) and
42+
* two parameters: the `event` that caused the transition and the `newState` ([TCurrentState]).
43+
*
44+
* - The receiver (`this`) is the state *before* the transition. It is `null` if the
45+
* current state is the initial state of the state machine.
46+
* - The `event` parameter is the event that triggered the transition into this state. It's
47+
* `null` if this is the initial state.
48+
* - The `newState` parameter is the instance of the state being entered.
49+
*
50+
* Example:
51+
* ```
52+
* state<MyState.Loading> {
53+
* // `this` is the old state, `event` is the trigger, `loadingState` is the new state instance.
54+
* onEnter { event, loadingState ->
55+
* println("Entered Loading state from ${this?.javaClass?.simpleName} due to event: $event")
56+
* // e.g., show a progress spinner using data from loadingState
57+
* }
58+
* }
59+
* ```
60+
*
61+
* @param block A lambda function that will be executed upon entering the state.
62+
* It has the old state (`TState?`) as its receiver and receives the triggering
63+
* event (`TEvent?`) and the new state instance (`TCurrentState`) as parameters.
64+
*/
65+
fun onEnter(block: TState?.(event: TEvent?, newState: TCurrentState) -> Unit)
66+
67+
/**
68+
* Defines an action to be executed when the state machine exits this state.
69+
*
70+
* This action is invoked just before the state machine transitions to a new state. It is useful
71+
* for performing cleanup tasks, logging, or other side effects associated with leaving a state.
72+
*
73+
* The provided [block] is a lambda with the *current state* ([TCurrentState]) as its receiver
74+
* and two parameters: the `event` that triggered.
75+
*
76+
* - The receiver (`this`) is the state instance being exited.
77+
* - The `event` parameter is the event that triggered the transition out of this state.
78+
*
79+
* Note: If this state is marked as a final state (`isFinalState = true`), the `onExit`
80+
* block will be called immediately after the `onEnter` block, as the machine's lifecycle ends.
81+
* In this specific case, the `event` parameter will always be `null`.
82+
*
83+
* Example:
84+
* ```
85+
* state<MyState.Active> {
86+
* // `this` is the Active state instance, `event` is the trigger, `newState` is the next state.
87+
* onExit { event ->
88+
* println("Exiting ${this::class.simpleName} state for new state due to event: $event")
89+
* // e.g., stop a timer or hide a UI component
90+
* }
91+
* // ... transitions
92+
* }
93+
* ```
94+
*
95+
* @param block A lambda function that will be executed upon exiting the state.
96+
* It has the current state ([TCurrentState]) as its receiver and receives the
97+
*/
98+
fun onExit(block: TCurrentState.(event: TEvent?) -> Unit)
99+
}
100+
101+
/**
102+
* A builder for defining a specific state within a [StateMachine].
103+
*
104+
* This builder provides a DSL for configuring a state, including:
105+
* - Defining transitions to other states via the [transition] function.
106+
* - Setting entry and exit actions with [onEnter] and [onExit].
107+
* - Marking a state as final with [isFinalState].
108+
*
109+
* It inherits from [StateWithoutTransactionsBuilder] to provide the base configuration methods.
110+
*
111+
* @param TCurrentState The specific type of the state being configured.
112+
* @param TState The base type for all states in the state machine.
113+
* @param TEvent The base type for all events that can trigger transitions.
114+
*/
115+
abstract class StateBuilder<TCurrentState : TState, TState : Any, TEvent : Any> :
116+
StateWithoutTransactionsBuilder<TCurrentState, TState, TEvent> {
117+
118+
/**
119+
* Defines a transition from the current state to a new state upon receiving a specific event.
120+
*
121+
* This function is used within the state machine DSL to declare how the machine should react
122+
* to an event when it's in the state being configured. The transition will only occur if the
123+
* incoming event matches the [targetEvent] and the optional [guard] condition is met.
124+
*
125+
* @param targetEvent The [KClass] of the event that triggers this transition.
126+
* @param guard An optional predicate function that must return `true` for the transition to proceed.
127+
* It receives the current state and the incoming event. If not provided, it defaults
128+
* to a function that always returns `true`.
129+
* @param block A function that executes to produce the new state. It receives the current
130+
* state and the incoming event, and its return value becomes the new state of the
131+
* state machine.
132+
*/
133+
abstract fun transition(
134+
currentState: KClass<out TCurrentState>,
135+
targetEvent: KClass<out TEvent>,
136+
guard: (currentState: TCurrentState, event: TEvent) -> Boolean = { _, _ -> true },
137+
block: (currentState: TCurrentState, event: TEvent) -> TState,
138+
)
139+
140+
/**
141+
* Defines a transition from the current state to a new state upon receiving a specific event.
142+
*
143+
* This function is used within the state machine DSL to declare how the machine should react
144+
* to an event when it's in the state being configured. The transition will only occur if the
145+
* incoming event matches the [TTargetEvent] and the optional [guard] condition is met.
146+
*
147+
* @param TTargetEvent The [KClass] of the event that triggers this transition.
148+
* @param guard An optional predicate function that must return `true` for the transition to proceed.
149+
* It receives the current state and the incoming event. If not provided, it defaults
150+
* to a function that always returns `true`.
151+
* @param block A function that executes to produce the new state. It receives the current
152+
* state and the incoming event, and its return value becomes the new state of the
153+
* state machine.
154+
*/
155+
@OptIn(ExperimentalContracts::class)
156+
inline fun <reified TTargetEvent : TEvent> transition(
157+
noinline guard: (currentState: TCurrentState, event: TTargetEvent) -> Boolean = { _, _ -> true },
158+
noinline block: (currentState: TCurrentState, event: TTargetEvent) -> TState,
159+
) {
160+
transition(
161+
currentState = stateClass,
162+
targetEvent = TTargetEvent::class,
163+
guard = { currentState, event -> guard(currentState, event as TTargetEvent) },
164+
block = { currentState, event -> block(currentState, event as TTargetEvent) },
165+
)
166+
}
167+
}
168+
169+
@StateMachineBuilderDsl
170+
internal class InternalStateBuilder<TCurrentState : TState, TState : Any, TEvent : Any>(
171+
override val stateClass: KClass<out TCurrentState>,
172+
) : StateBuilder<TCurrentState, TState, TEvent>() {
173+
override var isFinalState = false
174+
private val transitions = mutableMapOf<TransactionKey<out TState, out TEvent>, Transition<TState, TEvent>>()
175+
private var onEnter: (TState?.(TEvent?, TCurrentState) -> Unit)? = null
176+
private var onExit: (TCurrentState.(TEvent?) -> Unit)? = null
177+
178+
override fun onEnter(block: TState?.(event: TEvent?, newState: TCurrentState) -> Unit) {
179+
onEnter = block
180+
}
181+
182+
override fun onExit(block: TCurrentState.(TEvent?) -> Unit) {
183+
onExit = block
184+
}
185+
186+
override fun transition(
187+
currentState: KClass<out TCurrentState>,
188+
targetEvent: KClass<out TEvent>,
189+
guard: (currentState: TCurrentState, event: TEvent) -> Boolean,
190+
block: (currentState: TCurrentState, event: TEvent) -> TState,
191+
) {
192+
val key = stateClass to targetEvent
193+
194+
// We wrap the specific types in a generic handler to store them safely
195+
@Suppress("UNCHECKED_CAST")
196+
val transition = Transition<TState, TEvent>(
197+
guard = { s, e -> guard(s as TCurrentState, e) },
198+
createNewState = { s, e -> block(s as TCurrentState, e) },
199+
)
200+
201+
transitions[key] = transition
202+
}
203+
204+
/**
205+
* Finalizes the configuration for the current state and constructs the internal representation
206+
* used by the state machine.
207+
*
208+
* This function is called internally by the state machine builder after the DSL block for this
209+
* state has been executed. It gathers all the defined transitions, `onEnter`/`onExit` actions,
210+
* and the `isFinalState` flag, and packages them into a [StateRegistry] object.
211+
*
212+
* @return A [Pair] where the first element is the [KClass] of the state being built, and the
213+
* second element is the [DefaultStateMachine.StateRegistry] containing all the
214+
* configuration for that state.
215+
*/
216+
fun build(): StateRegistry<TCurrentState, TState, TEvent> {
217+
return StateRegistry(
218+
stateClass = stateClass,
219+
isFinalState = isFinalState,
220+
listeners = StateListeners(onEnter = onEnter, onExit = onExit),
221+
transitions = transitions,
222+
)
223+
}
224+
}

0 commit comments

Comments
 (0)