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 }