Coverage Summary for Class: StateMachine (vit.khudenko.android.fsm)
| Class | Method, % | Block, % | Line, % |
|---|---|---|---|
| StateMachine | 100% (6/6) | 100% (7/7) | 100% (21/21) |
| StateMachine$Builder | 100% (5/5) | 57.1% (4/7) | 100% (28/28) |
| StateMachine$Listener | |||
| StateMachine$Transition | 100% (2/2) | 100% (4/4) | 100% (10/10) |
| StateMachine$Transition$Identity | 100% (1/1) | 100% (3/3) | |
| Total | 100% (14/14) | 83.3% (15/18) | 100% (62/62) |
1 package vit.khudenko.android.fsm 2 3 import java.util.Collections 4 import kotlin.collections.set 5 6 /** 7 * `StateMachine` is a general purpose finite-state machine. 8 * 9 * A sample configuration (assuming your app has `Session` class, that defines specific to your app events and states 10 * (`Session.Event` and `Session.State` enums): 11 * 12 * ``` 13 * val sessionStateMachine = StateMachine.Builder<Session.Event, Session.State>() 14 * .setInitialState(Session.State.ACTIVE) 15 * .addTransition( 16 * StateMachine.Transition( 17 * event = Session.Event.LOGIN, 18 * statePath = listOf(Session.State.INACTIVE, Session.State.ACTIVE) 19 * ) 20 * ) 21 * .addTransition( 22 * StateMachine.Transition( 23 * event = Session.Event.LOGOUT, 24 * statePath = listOf(Session.State.ACTIVE, Session.State.INACTIVE) 25 * ) 26 * ) 27 * .addTransition( 28 * StateMachine.Transition( 29 * event = Session.Event.LOGOUT_AND_FORGET, 30 * statePath = listOf(Session.State.ACTIVE, Session.State.FORGOTTEN) 31 * ) 32 * ) 33 * .build() 34 * ``` 35 * 36 * It is also possible to define transitions in a more concise way: 37 * 38 * ``` 39 * val sessionStateMachine = StateMachine.Builder<Session.Event, Session.State>() 40 * // .. 41 * .addTransitions( 42 * Session.Event.LOGIN to listOf(Session.State.INACTIVE, Session.State.ACTIVE), 43 * Session.Event.LOGOUT to listOf(Session.State.ACTIVE, Session.State.INACTIVE), 44 * Session.Event.LOGOUT_AND_FORGET to listOf(Session.State.ACTIVE, Session.State.FORGOTTEN) 45 * ) 46 * ``` 47 * 48 * The implementation is thread-safe. Public API methods are declared as `synchronized`. 49 * 50 * @param [Event] event parameter of enum type. 51 * @param [State] state parameter of enum type. 52 * 53 * @see [StateMachine.Builder] 54 */ 55 class StateMachine<Event : Enum<Event>, State : Enum<State>> private constructor( 56 private val graph: Map<Transition.Identity<Event, State>, List<State>>, 57 initialState: State 58 ) { 59 60 /** 61 * A callback to communicate state changes of a [`StateMachine`][StateMachine]. 62 */ 63 fun interface Listener<State> { 64 fun onStateChanged(oldState: State, newState: State) 65 } 66 67 /** 68 * Builder is not thread-safe. 69 * 70 * @param [Event] event parameter of enum type. 71 * @param [State] state parameter of enum type. 72 */ 73 class Builder<Event : Enum<Event>, State : Enum<State>> { 74 75 private val graph = HashMap<Transition.Identity<Event, State>, List<State>>() 76 private lateinit var initialState: State 77 78 /** 79 * @param transition [`Transition`][Transition]<[`Event`][Event], [`State`][State]>, a definition of 80 * a state path for a give event. 81 * 82 * @return [`StateMachine.Builder`][StateMachine.Builder] 83 * 84 * @throws [StateMachineBuilderValidationException] if a duplicate transition identified (by a combination 85 * of event and starting state) 86 * 87 * @see [Transition] 88 */ 89 fun addTransition(transition: Transition<Event, State>): Builder<Event, State> { 90 val statePathCopy = transition.statePath.toMutableList() 91 val startState = statePathCopy.removeAt(0) 92 93 if (graph.containsKey(transition.identity)) { 94 val cause = "duplicate transition: a transition for event '" + transition.event + 95 "' and starting state '" + startState + "' is already present" 96 throw StateMachineBuilderValidationException(cause) 97 } 98 99 graph[transition.identity] = Collections.unmodifiableList(statePathCopy) 100 101 return this 102 } 103 104 /** 105 * @param transitions vararg of transition definitions. 106 * 107 * @return [`StateMachine.Builder`][StateMachine.Builder] 108 * 109 * @throws [StateMachineBuilderValidationException] if a duplicate transition identified (by a combination 110 * of event and starting state) 111 * 112 * @see [Transition] 113 */ 114 fun addTransitions(vararg transitions: Pair<Event, List<State>>): Builder<Event, State> { 115 transitions.forEach { 116 addTransition(it.let { (event, statePath) -> Transition(event, statePath) }) 117 } 118 return this 119 } 120 121 /** 122 * @param state [`State`][State] 123 * 124 * @return [`StateMachine.Builder`][StateMachine.Builder] 125 */ 126 fun setInitialState(state: State): Builder<Event, State> { 127 this.initialState = state 128 return this 129 } 130 131 /** 132 * @return [`StateMachine`][StateMachine] a newly created instance. 133 * 134 * @throws [StateMachineBuilderValidationException] if initial state has not been set (see [setInitialState]) 135 * @throws [StateMachineBuilderValidationException] if no transitions have been added (see [addTransition]) 136 * @throws [StateMachineBuilderValidationException] if no transition defined with starting state matching 137 * the initial state 138 */ 139 fun build(): StateMachine<Event, State> { 140 if (this::initialState.isInitialized.not()) { 141 throw StateMachineBuilderValidationException( 142 "initial state is not defined, make sure to call ${StateMachine::class.java.simpleName}" + 143 ".${javaClass.simpleName}.setInitialState()" 144 ) 145 } 146 if (graph.isEmpty()) { 147 throw StateMachineBuilderValidationException( 148 "no transitions defined, make sure to call ${StateMachine::class.java.simpleName}" + 149 ".${javaClass.simpleName}.addTransition()" 150 ) 151 } 152 if (graph.keys.map { transitionIdentity -> transitionIdentity.state }.contains(initialState).not()) { 153 throw StateMachineBuilderValidationException( 154 "no transition defined with start state matching the initial state ($initialState)" 155 ) 156 } 157 return StateMachine(HashMap(graph), initialState) 158 } 159 } 160 161 private var currentState: State = initialState 162 private val listeners: LinkedHashSet<Listener<State>> = LinkedHashSet() 163 private var inTransition: Boolean = false 164 165 /** 166 * Adds [`listener`][listener] to this `StateMachine`. 167 * 168 * If this [`listener`][listener] has been already added, then this call is no op. 169 */ 170 @Synchronized 171 fun addListener(listener: Listener<State>) { 172 if (!listeners.contains(listener)) { 173 listeners.add(listener) 174 } 175 } 176 177 /** 178 * Removes all listeners from this `StateMachine`. 179 */ 180 @Synchronized 181 fun removeAllListeners() { 182 listeners.clear() 183 } 184 185 /** 186 * Removes [`listener`][listener] from this `StateMachine`. 187 */ 188 @Synchronized 189 fun removeListener(listener: Listener<State>) { 190 listeners.remove(listener) 191 } 192 193 /** 194 * Moves the `StateMachine` from the current state to a new one (if there is a matching transition). 195 * 196 * Depending on configuration of this `StateMachine` there may be several state changes for one 197 * [`consumeEvent()`][consumeEvent] call. 198 * 199 * Missed [`consumeEvent()`][consumeEvent] calls (meaning no matching transition found) are ignored (no op). 200 * 201 * State changes are communicated via the [`StateMachine.Listener`][StateMachine.Listener] listeners. 202 * 203 * @param event [`Event`][Event] 204 * 205 * @return flag of whether the event was actually consumed (meaning moving to a new state) or ignored. 206 * 207 * @throws [IllegalStateException] if there is a matching transition for this event and current state, 208 * but there is still an unfinished transition in progress. 209 */ 210 @Synchronized 211 fun consumeEvent(event: Event): Boolean { 212 val transitionIdentity = Transition.Identity(event, currentState) 213 val transition = graph[transitionIdentity] ?: return false 214 215 check(!inTransition) { "there is a transition which is still in progress" } 216 217 val len = transition.size 218 for (i in 0 until len) { 219 inTransition = (i < (len - 1)) 220 val oldState = currentState 221 val newState = transition[i] 222 currentState = newState 223 for (listener in ArrayList(listeners)) { 224 listener.onStateChanged(oldState, newState) 225 } 226 } 227 228 return true 229 } 230 231 @Synchronized 232 fun getCurrentState(): State { 233 return currentState 234 } 235 236 /** 237 * A transition defines its identity as a pair of the [`event`][event] and the starting state 238 * (the first item in the [`statePath`][statePath]). `StateMachine` allows unique transitions 239 * only (each transition must have a unique identity). 240 * 241 * @param event [`Event`][Event] - triggering event for this transition. 242 * @param statePath a list of states for this transition. 243 * First item is a starting state for the transition. 244 * Must have at least two items. Must not have repeating items in a row. 245 * 246 * @throws [IllegalArgumentException] if statePath is empty or has a single item 247 * @throws [IllegalArgumentException] if statePath has repeating items in a row 248 * 249 * @param [Event] event parameter of enum type. 250 * @param [State] state parameter of enum type. 251 */ 252 class Transition<Event : Enum<Event>, State : Enum<State>>( 253 val event: Event, 254 val statePath: List<State> 255 ) { 256 val identity: Identity<Event, State> 257 258 init { 259 require(statePath.size > 1) { "statePath must contain at least 2 items" } 260 require(statePath.zipWithNext().none { (s1, s2) -> s1 == s2 }) { 261 "statePath must not have repeating items in a row" 262 } 263 identity = Identity(event, statePath.first()) 264 } 265 266 data class Identity<Event : Enum<Event>, State : Enum<State>>( 267 val event: Event, 268 val state: State 269 ) 270 } 271 }