Coverage Summary for Class: SessionTracker (vit.khudenko.android.sessiontracker)

Class Method, % Block, % Line, %
SessionTracker 100% (13/13) 100% (37/37) 100% (149/149)
SessionTracker$Companion 100% (1/1) 100% (1/1)
SessionTracker$doUntrackSession$1 100% (1/1) 100% (1/1)
SessionTracker$getSessionRecords$dump$1 100% (1/1) 100% (1/1)
SessionTracker$Listener
SessionTracker$Logger
SessionTracker$Logger$DefaultImpl 100% (4/4) 100% (4/4)
SessionTracker$Mode 100% (2/2) 100% (5/5)
SessionTracker$SessionInfo 100% (1/1) 100% (3/3)
SessionTracker$setupSessionStateMachine$2 100% (2/2) 50% (3/6) 100% (18/18)
SessionTracker$setupSessionStateMachine$2$onStateChanged$3 100% (1/1) 100% (1/1)
SessionTracker$trackSession$2 100% (1/1) 100% (1/1)
SessionTracker$untrackAllSessions$1 100% (1/1) 100% (1/1)
Total 100% (28/28) 93% (40/43) 100% (185/185)


1 package vit.khudenko.android.sessiontracker 2  3 import android.util.Log 4 import vit.khudenko.android.fsm.StateMachine 5  6 /** 7  * ## TL;DR 8  * 9  * SessionTracker is a general purpose framework to provide a foundation for session management in your app. 10  * 11  * Your app provides (a) session tracking storage implementation and (b) session tracking state machine configuration, 12  * while SessionTracker provides callbacks to create/update/release session resources. 13  * 14  * ## Contract description 15  * 16  * ### What is session? 17  * 18  * Session is a flexible entity - it could be a user session with user signing in/out or a bluetooth device session 19  * with all its possible states. 20  * 21  * ### Session tracking state VS. session state 22  * 23  * In SessionTracker framework, sessions are represented by session tracking records - instances of 24  * [`SessionRecord`][SessionRecord]. It is an immutable data structure of 2 fields - session ID and 25  * session tracking state. 26  * 27  * Note, there are two (partially intersecting) types of session state: 28  * 1. The session state that is tracked by SessionTracker, which is always an instance of enum by the contract. 29  * 2. The session state that is specific to your app, which can be as diverse as your app's business logic requires 30  * and which can not be represented by the [`SessionRecord`][SessionRecord]. 31  * 32  * Please don't mess one with another. Actual implementation of session, including its persistence, is up to your app 33  * and is out of SessionTracker framework responsibility. It is correct to say that session tracking state (the one 34  * tracked by SessionTracker) is a subset of a full session state in your app. 35  * 36  * ### Persistence 37  * 38  * SessionTracker framework supports session tracking auto-restoration on application process restarts. Your 39  * app must provide an implementation of [`ISessionTrackerStorage`][ISessionTrackerStorage], which is used by 40  * SessionTracker to make CRUD operations on session tracking records. 41  * 42  * ### Session tracking state machine 43  * 44  * SessionTracker maintains a state machine per session. Your app must define a set of possible events and 45  * states per session. Using events and states, your app should provide state machine transitions, which are 46  * used to configure session state machine. For example, your app may define the following session tracking events 47  * and states: 48  * 49  * ```kotlin 50  * enum class State { 51  * INACTIVE, ACTIVE 52  * } 53  * enum class Event { 54  * LOGIN, LOGOUT 55  * } 56  * ``` 57  * 58  * then a sample transitions config (an implementation of 59  * [`ISessionStateTransitionsSupplier`][ISessionStateTransitionsSupplier]) could be as following: 60  * 61  * ```kotlin 62  * val sessionStateTransitionsSupplier = object : ISessionStateTransitionsSupplier<Event, State> { 63  * override fun getStateTransitions(sessionId: SessionId) = listOf( 64  * Transition( 65  * event = Event.LOGIN, 66  * statePath = listOf(State.INACTIVE, State.ACTIVE) 67  * ), 68  * Transition( 69  * event = Event.LOGOUT, 70  * statePath = listOf(State.ACTIVE, State.INACTIVE) 71  * ) 72  * ) 73  * } 74  * ``` 75  * 76  * Such config would mean there are two possible session tracking states (`ACTIVE`/`INACTIVE`) and two possible session 77  * tracking events: `LOGIN` (to move session from `INACTIVE` to `ACTIVE` state) and `LOGOUT` (to move session from 78  * `ACTIVE` to `INACTIVE` state). 79  * 80  * ### Session tracking 81  * 82  * In order to make SessionTracker ready to function it should be initialized first. The most appropriate place for 83  * [`initialize(sessionTrackerListener: Listener<Event, State>)`][initialize] call is 84  * [`Application.onCreate()`][android.app.Application.onCreate]. 85  * 86  * Suppose your user hits "Login" button, your app authenticates user and creates a session. In order to make 87  * use of SessionTracker the session should be "attached" to SessionTracker: 88  * 89  * ```kotlin 90  * sessionTracker.trackSession(sessionId, State.ACTIVE) 91  * ``` 92  * 93  * Now SessionTracker is tracking the session until your app calls [`untrackSession(sessionId)`][untrackSession]. 94  * Next time your app starts (and SessionTracker is initialized), the session tracking will be automatically restored 95  * by SessionTracker with the same `ACTIVE` state. 96  * 97  * As long as session is tracked, its session tracking state changes are propagated to your app via 98  * [`SessionTracker.Listener`][SessionTracker.Listener]. 99  * 100  * Suppose eventually your user hits "Log Out" button, then your app is responsible to communicate this event 101  * to SessionTracker by asking to consume `LOGOUT` event for the session: 102  * 103  * ```kotlin 104  * sessionTracker.consumeEvent(sessionId, Event.LOGOUT) 105  * ``` 106  * 107  * Now SessionTracker updates session tracking state to `INACTIVE`, persists session record with the new state and 108  * propagates this state change via [`SessionTracker.Listener`][SessionTracker.Listener]. Note, the session 109  * is still tracked by SessionTracker, so next time your app starts, the session tracking will be automatically restored 110  * by SessionTracker with the same `INACTIVE` state. 111  * 112  * ### Management of session resources 113  * 114  * [`SessionTracker.Listener`][SessionTracker.Listener] has useful for your app callbacks that allow to manage session 115  * resources appropriately: 116  * 117  * - `onSessionTrackerInitialized(sessionTracker: SessionTracker<Event, State>, sessionRecords: List<SessionRecord<State>>)` - 118  * SessionTracker has added sessions to the list of tracked sessions. 119  * This happens as a result of calling [`SessionTracker.initialize(sessionTrackerListener: Listener<Event, State>)`][initialize]. 120  * This callback is the right place to create any resources for the sessions (a DB connection, a DI scope, etc.). 121  * 122  * - `onSessionTrackingStarted(sessionTracker: SessionTracker<Event, State>, sessionRecord: SessionRecord<State>)` - 123  * SessionTracker has added session to the list of tracked sessions. 124  * This happens as a result of calling [`SessionTracker.trackSession(sessionId, state)`][trackSession]. 125  * This callback is the right place to create any resources for the session (a DB connection, a DI scope, etc.) 126  * depending on the session state (`sessionRecord.state`). 127  * 128  * - `onSessionStateChanged(sessionTracker: SessionTracker<Event, State>, sessionRecord: SessionRecord<State>, oldState: State)` - 129  * session tracking state has changed. 130  * This happens as a result of calling [`SessionTracker.consumeEvent(sessionId, event)`][consumeEvent]. 131  * This callback is the right place to create or release any resources for the session (a DB connection, 132  * a DI scope, etc.). 133  * 134  * - `onSessionTrackingStopped(sessionTracker: SessionTracker<Event, State>, sessionRecord: SessionRecord<State>)` - 135  * SessionTracker has removed session from the list of tracked sessions. This happens as a result 136  * of calling [`SessionTracker.untrackSession(sessionId)`][untrackSession]. 137  * This may also happen as a result of calling [`SessionTracker.consumeEvent`][consumeEvent] if session 138  * appears in one of the [`autoUntrackStates`][autoUntrackStates]. 139  * This callback is the right place to release any resources for the session (a DB connection, a DI scope, etc.). 140  * 141  * - `onAllSessionsTrackingStopped(sessionTracker: SessionTracker<Event, State>, sessionRecords: List<SessionRecord<State>>)` - 142  * SessionTracker has removed session from the list of tracked sessions. This happens as a result 143  * of calling [`SessionTracker.untrackAllSessions()`][untrackAllSessions]. 144  * This callback is the right place to release any resources for the sessions (a DB connection, a DI scope, etc.). 145  * 146  * ## Threading 147  * 148  * SessionTracker is thread-safe. Public methods are declared as `synchronized`. Thread-safe compound actions are 149  * possible by using synchronized statement on `SessionTracker` instance: 150  * 151  * ```kotlin 152  * synchronized(sessionTracker) { 153  * sessionTracker.consumeEvent(..) // step 1 of the compound action 154  * sessionTracker.consumeEvent(..) // step 2 of the compound action 155  * } 156  * ``` 157  * 158  * SessionTracker is a synchronous tool, meaning it neither creates threads nor uses thread-pools. 159  * 160  * ## Miscellaneous 161  * 162  * Typical SessionTracker usage implies being a singleton in your app. 163  */ 164 class SessionTracker<Event : Enum<Event>, State : Enum<State>>( 165  private val sessionTrackerStorage: ISessionTrackerStorage<State>, 166  private val sessionStateTransitionsSupplier: ISessionStateTransitionsSupplier<Event, State>, 167  /** 168  * If a session appears in one of these states, then `SessionTracker` automatically untracks such session. 169  * The effect of automatic untracking is similar to making an explicit [`untrackSession()`][untrackSession] call. 170  * 171  * @see [consumeEvent] 172  * @see [untrackSession] 173  * @see [untrackAllSessions] 174  */ 175  private val autoUntrackStates: Set<State>, 176  private val mode: Mode, 177  private val logger: Logger = Logger.DefaultImpl(), 178  private val logTag: String = TAG 179 ) { 180  181  companion object { 182  internal val TAG = SessionTracker::class.java.simpleName 183  } 184  185  /** 186  * Defines misuse/misconfiguration tolerance and amount of logging. 187  * 188  * @see [Mode.STRICT] 189  * @see [Mode.STRICT_VERBOSE] 190  * @see [Mode.RELAXED] 191  * @see [Mode.RELAXED_VERBOSE] 192  */ 193  enum class Mode(val strict: Boolean, val verbose: Boolean) { 194  195  /** 196  * In this mode SessionTracker does not tolerate most of the misuse/misconfiguration issues by crashing the app. 197  * 198  * @see [initialize] 199  * @see [trackSession] 200  * @see [untrackSession] 201  * @see [consumeEvent] 202  */ 203  STRICT(strict = true, verbose = false), 204  205  /** 206  * Same as [`STRICT`][STRICT], but with more logging. 207  */ 208  STRICT_VERBOSE(strict = true, verbose = true), 209  210  /** 211  * In this mode SessionTracker tries to overstep misuse/misconfiguration issues (if possible) by just logging 212  * the issue and turning an operation to 'no op'. 213  * 214  * @see [initialize] 215  * @see [trackSession] 216  * @see [untrackSession] 217  * @see [consumeEvent] 218  */ 219  RELAXED(strict = false, verbose = false), 220  221  /** 222  * Same as [`RELAXED`][RELAXED], but with more logging. 223  */ 224  RELAXED_VERBOSE(strict = false, verbose = true) 225  } 226  227  /** 228  * A listener, through which the session tracking lifecycle and state changes are communicated. 229  * 230  * @see [onSessionTrackerInitialized] 231  * @see [onSessionTrackingStarted] 232  * @see [onSessionTrackingStopped] 233  * @see [onSessionStateChanged] 234  * @see [onAllSessionsTrackingStopped] 235  */ 236  interface Listener<Event : Enum<Event>, State : Enum<State>> { 237  238  /** 239  * The `SessionTracker` has added session(s) to the list of tracked sessions. This happens as a result 240  * of calling [`SessionTracker.initialize(sessionTrackerListener: Listener<Event, State>)`][initialize]. 241  * 242  * This callback is the right place to create any resources for the session 243  * (a DB connection, a DI scope, etc.). 244  */ 245  fun onSessionTrackerInitialized( 246  sessionTracker: SessionTracker<Event, State>, 247  sessionRecords: List<SessionRecord<State>> 248  ) 249  250  /** 251  * The `SessionTracker` has added session to the list of tracked sessions. This happens as a result 252  * of calling [`SessionTracker.trackSession()`][trackSession]. 253  * 254  * This callback is the right place to create any resources for the session 255  * (a DB connection, a DI scope, etc.). 256  */ 257  fun onSessionTrackingStarted( 258  sessionTracker: SessionTracker<Event, State>, 259  sessionRecord: SessionRecord<State> 260  ) 261  262  /** 263  * The session tracking state has changed from `oldState` to `newState`. 264  * This happens as a result of calling [`SessionTracker.consumeEvent()`][consumeEvent]. 265  * 266  * This callback is the right place to create or release any resources 267  * for the session (a DB connection, a DI scope, etc.). 268  */ 269  fun onSessionStateChanged( 270  sessionTracker: SessionTracker<Event, State>, 271  sessionRecord: SessionRecord<State>, 272  oldState: State 273  ) 274  275  /** 276  * The `SessionTracker` has removed session from the list of tracked sessions. This happens as a result 277  * of calling [`SessionTracker.untrackSession()`][untrackSession]. 278  * 279  * This may also happen as a result of calling [`SessionTracker.consumeEvent()`][SessionTracker.consumeEvent] 280  * if session appears in one of the [`autoUntrackStates`][autoUntrackStates]. 281  * 282  * This callback is the right place to release any resources for 283  * the session (a DB connection, a DI scope, etc.). 284  */ 285  fun onSessionTrackingStopped( 286  sessionTracker: SessionTracker<Event, State>, 287  sessionRecord: SessionRecord<State> 288  ) 289  290  /** 291  * The `SessionTracker` has removed all sessions from the list of tracked sessions. This happens as a result 292  * of calling [`SessionTracker.untrackAllSessions()`][untrackAllSessions]. 293  * 294  * This callback is the right place to release any resources for 295  * the sessions (a DB connection, a DI scope, etc.). 296  */ 297  fun onAllSessionsTrackingStopped( 298  sessionTracker: SessionTracker<Event, State>, 299  sessionRecords: List<SessionRecord<State>> 300  ) 301  } 302  303  interface Logger { 304  fun d(tag: String, message: String) 305  fun w(tag: String, message: String) 306  fun e(tag: String, message: String) 307  308  /** 309  * Default implementation of [`Logger`][Logger] that uses [`android.util.Log`][android.util.Log]. 310  */ 311  class DefaultImpl : Logger { 312  override fun d(tag: String, message: String) { 313  Log.d(tag, message) 314  } 315  316  override fun w(tag: String, message: String) { 317  Log.w(tag, message) 318  } 319  320  override fun e(tag: String, message: String) { 321  Log.e(tag, message) 322  } 323  } 324  } 325  326  private var initialized: Boolean = false 327  private val sessionsMap = LinkedHashMap<SessionId, SessionInfo<Event, State>>() 328  private var persisting = false 329  private var listener: Listener<Event, State>? = null 330  331  /** 332  * Must be called before calling any other methods. 333  * 334  * Subsequent calls are ignored. 335  * 336  * This method calls [`ISessionTrackerStorage.readAllSessionRecords()`][ISessionTrackerStorage.readAllSessionRecords], 337  * starts tracking the obtained session records and notifies [`sessionTrackerListener`][sessionTrackerListener] 338  * (see [`Listener.onSessionTrackingStarted()`][Listener.onSessionTrackingStarted]). 339  * 340  * @param sessionTrackerListener [`Listener`][Listener] to communicate the session tracking lifecycle and state changes. 341  * 342  * @throws [RuntimeException] for a strict [`mode`][mode], if [`autoUntrackStates`][autoUntrackStates] are defined 343  * AND session is in one of such states. For a relaxed [`mode`][mode] it just logs an error message and skips such 344  * session from tracking. 345  * @throws [RuntimeException] for a strict [`mode`][mode], if 346  * [`sessionStateTransitionsSupplier`][sessionStateTransitionsSupplier] returns transitions that cause validation 347  * errors while creating session tracking state machine. For a relaxed [`mode`][mode] it just logs an error 348  * message and skips such session from tracking. 349  */ 350  @Synchronized 351  fun initialize(sessionTrackerListener: Listener<Event, State>) { 352  val startedAt = System.currentTimeMillis() 353  354  if (initialized) { 355  logger.w(logTag, "initialize: already initialized, skipping..") 356  return 357  } 358  359  if (mode.verbose) { 360  logger.d(logTag, "initialize: starting..") 361  } 362  363  this.listener = sessionTrackerListener 364  365  val loadedSessionRecords = sessionTrackerStorage.readAllSessionRecords() 366  367  loadedSessionRecords 368  .filter { sessionRecord -> 369  sessionRecord.state in autoUntrackStates 370  }.forEach { (sessionId, state) -> 371  val explanation = "session with ID '${sessionId.value}' is in auto-untrack state (${state})" 372  if (mode.strict) { 373  throw RuntimeException("Unable to initialize $logTag: $explanation") 374  } else { 375  logger.e(logTag, "initialize: $explanation, rejecting this session") 376  } 377  } 378  379  val initializedSessionRecords = mutableMapOf<SessionId, SessionRecord<State>>() 380  381  loadedSessionRecords 382  .filterNot { sessionRecord -> 383  sessionRecord.state in autoUntrackStates 384  } 385  .map { sessionRecord -> 386  val stateMachine = try { 387  setupSessionStateMachine(sessionRecord) 388  } catch (e: Exception) { 389  throw RuntimeException( 390  "Unable to initialize $logTag: error creating ${StateMachine::class.java.simpleName}", e 391  ) 392  } 393  sessionRecord to stateMachine 394  } 395  .forEach { (sessionRecord, stateMachine) -> 396  sessionsMap[sessionRecord.sessionId] = SessionInfo(stateMachine) 397  initializedSessionRecords[sessionRecord.sessionId] = sessionRecord 398  } 399  400  initialized = true 401  402  sessionTrackerListener.onSessionTrackerInitialized(this, initializedSessionRecords.values.toList()) 403  404  if (mode.verbose) { 405  logger.d(logTag, "initialize: done, took ${System.currentTimeMillis() - startedAt} ms") 406  } 407  } 408  409  /** 410  * @return a list of the currently tracked session records. 411  * 412  * @throws [RuntimeException] for a strict [`mode`][mode], if `SessionTracker` has not been initialized. 413  * For a relaxed [`mode`][mode] it just logs an error message and returns an empty list. 414  */ 415  @Synchronized 416  fun getSessionRecords(): List<SessionRecord<State>> { 417  return if (ensureInitialized("getSessionRecords")) { 418  val sessionRecords = sessionsMap.entries.map { 419  SessionRecord(sessionId = it.key, state = it.value.stateMachine.getCurrentState()) 420  }.toMutableList() 421  if (mode.verbose) { 422  val dump = sessionRecords.joinToString( 423  prefix = "[", postfix = "]" 424  ) { (sessionId, state) -> "{ '${sessionId.value}': $state }" } 425  logger.d(logTag, "getSessionRecords: $dump") 426  } 427  sessionRecords 428  } else { 429  emptyList() 430  } 431  } 432  433  /** 434  * Starts tracking a session for the sessionId, persists a new session record via 435  * [`ISessionTrackerStorage`][ISessionTrackerStorage] and notifies session tracker listener 436  * (see [`SessionTracker.Listener.onSessionTrackingStarted()`][Listener.onSessionTrackingStarted]). 437  * 438  * If session with the same sessionId is already tracked, then the call does nothing. 439  * 440  * @param sessionId [`SessionId`][SessionId] - ID of the session to track. 441  * @param state [`State`][State] - initial session tracking state. 442  * 443  * @throws [IllegalArgumentException] for a strict [`mode`][mode], if [`autoUntrackStates`][autoUntrackStates] 444  * are defined AND sessionId is in one of such states. For a relaxed [`mode`][mode] it just logs an error message 445  * and does nothing. 446  * @throws [RuntimeException] for a strict [`mode`][mode], if `SessionTracker` has not been initialized. 447  * For a relaxed [`mode`][mode] it just logs an error message and does nothing. 448  * @throws [RuntimeException] for a strict [`mode`][mode], if 449  * [`sessionStateTransitionsSupplier`][sessionStateTransitionsSupplier] returns transitions that 450  * cause validation errors while creating session tracking state machine. For a relaxed [`mode`][mode] it just logs 451  * an error message and does nothing. 452  * @throws [RuntimeException] for a strict [`mode`][mode], if this call is initiated from the 453  * [`sessionTrackerStorage`][sessionTrackerStorage]. For a relaxed [`mode`][mode] it just logs an error message 454  * and does nothing. 455  */ 456  @Synchronized 457  @JvmName("trackSession") 458  fun trackSession(sessionId: SessionId, state: State) { 459  if (!ensureInitialized("trackSession")) { 460  return 461  } 462  if (mode.verbose) { 463  logger.d(logTag, "trackSession: sessionId = '${sessionId.value}', state = $state") 464  } 465  if (!ensureNotPersisting("trackSession")) { 466  return 467  } 468  if (sessionsMap.contains(sessionId)) { 469  logger.w(logTag, "trackSession: session with ID '${sessionId.value}' already exists") 470  } else { 471  if (state in autoUntrackStates) { 472  val explanation = "session with ID '${sessionId.value}' is in auto-untrack state ($state)" 473  require(mode.strict.not()) { "Unable to track session: $explanation" } 474  logger.e(logTag, "trackSession: $explanation, rejecting this session") 475  } else { 476  val sessionRecord = SessionRecord(sessionId, state) 477  val stateMachine = try { 478  setupSessionStateMachine(sessionRecord) 479  } catch (e: Exception) { 480  throw RuntimeException( 481  "$logTag failed to track session: error creating ${StateMachine::class.java.simpleName}", e 482  ) 483  } 484  doPersistAction { sessionTrackerStorage.createSessionRecord(sessionRecord) } 485  sessionsMap[sessionId] = SessionInfo(stateMachine) 486  listener!!.onSessionTrackingStarted(this@SessionTracker, sessionRecord) 487  } 488  } 489  } 490  491  /** 492  * Stops tracking a session with specified `sessionId`, removes corresponding session record from persistent storage 493  * (via [`ISessionTrackerStorage`][ISessionTrackerStorage] implementation) and notifies session tracker listener 494  * (see [`SessionTracker.Listener.onSessionTrackingStopped()`][Listener.onSessionTrackingStopped]). 495  * 496  * If `SessionTracker` does not track a session with specified `sessionId`, then this call does nothing. 497  * 498  * Note, this method does not modify session state. 499  * 500  * Note, it's possible to define [`autoUntrackStates`][autoUntrackStates] via `SessionTracker` constructor, so 501  * sessions are untracked automatically at [`SessionTracker.consumeEvent()`][consumeEvent]. 502  * 503  * @param sessionId [`SessionId`][SessionId]. 504  * 505  * @throws [RuntimeException] for a strict [`mode`][mode], if `SessionTracker` has not been initialized. 506  * For a relaxed [`mode`][mode] it just logs an error message and does nothing. 507  * @throws [RuntimeException] for a strict [`mode`][mode], if this call is initiated from the 508  * [`sessionTrackerStorage`][sessionTrackerStorage]. For a relaxed [`mode`][mode] it just logs an error message 509  * and does nothing. 510  */ 511  @Synchronized 512  @JvmName("untrackSession") 513  fun untrackSession(sessionId: SessionId) { 514  if (!ensureInitialized("untrackSession")) { 515  return 516  } 517  if (mode.verbose) { 518  logger.d(logTag, "untrackSession: sessionId = '${sessionId.value}'") 519  } 520  if (!ensureNotPersisting("untrackSession")) { 521  return 522  } 523  val sessionInfo = sessionsMap[sessionId] 524  if (sessionInfo == null) { 525  logger.d(logTag, "untrackSession: no session with ID '${sessionId.value}' found") 526  } else { 527  if (sessionInfo.isUntracking) { 528  logger.w(logTag, "untrackSession: session with ID '${sessionId.value}' is already untracking") 529  } else { 530  sessionsMap[sessionId] = sessionInfo.copy(isUntracking = true) 531  doUntrackSession(sessionId, sessionInfo.stateMachine) 532  } 533  } 534  } 535  536  /** 537  * Stops tracking all currently tracked sessions, removes session records from persistent storage (via 538  * [`ISessionTrackerStorage`][ISessionTrackerStorage] implementation) and notifies session tracker listener 539  * (see [`Listener.onAllSessionsTrackingStopped()`][Listener.onAllSessionsTrackingStopped]). 540  * 541  * If `SessionTracker` does not track any sessions, then this call does nothing. 542  * 543  * Note, this method does not modify session tracking state of the session records. 544  * 545  * Note, it's possible to define [`autoUntrackStates`][autoUntrackStates] via `SessionTracker` constructor, so 546  * sessions are untracked automatically at [`SessionTracker.consumeEvent()`][consumeEvent]. 547  * 548  * @throws [RuntimeException] for a strict [`mode`][mode], if `SessionTracker` has not been initialized. 549  * For a relaxed [`mode`][mode] it just logs an error message and does nothing. 550  * @throws [RuntimeException] for a strict [`mode`][mode], if this call is initiated from the 551  * [`sessionTrackerStorage`][sessionTrackerStorage]. For a relaxed [`mode`][mode] it just logs an error message 552  * and does nothing. 553  */ 554  @Synchronized 555  fun untrackAllSessions() { 556  if (!ensureInitialized("untrackAllSessions")) { 557  return 558  } 559  if (!ensureNotPersisting("untrackAllSessions")) { 560  return 561  } 562  if (sessionsMap.isEmpty()) { 563  if (mode.verbose) { 564  logger.d(logTag, "untrackAllSessions: no sessions found") 565  } 566  } else { 567  if (mode.verbose) { 568  logger.d(logTag, "untrackAllSessions") 569  } 570  571  doPersistAction { sessionTrackerStorage.deleteAllSessionRecords() } 572  573  sessionsMap.values.forEach { it.stateMachine.removeAllListeners() } 574  575  val sessionRecords = sessionsMap.entries.map { (sessionId, sessionInfo) -> 576  SessionRecord(sessionId, sessionInfo.stateMachine.getCurrentState()) 577  } 578  579  sessionsMap.clear() 580  581  listener!!.onAllSessionsTrackingStopped(this@SessionTracker, sessionRecords) 582  } 583  } 584  585  /** 586  * Attempts to apply the specified [`event`][event] to the specified session. Whether the event actually causes 587  * session tracking state change depends on the session state machine configuration and current session tracking 588  * state. If session tracking state change occurs, then updated session record is persisted 589  * (via [`ISessionTrackerStorage`][ISessionTrackerStorage]) and session tracking listener is notified. 590  * 591  * If, as a result of the event consuming, the session appears in a one of the 592  * [`autoUntrackStates`][autoUntrackStates] (assuming these were defined), then `SessionTracker` also stops 593  * tracking the session, removes corresponding session record from the persistent storage and notifies session 594  * tracking listener. 595  * 596  * If `SessionTracker` does not track a session with specified `sessionId`, then this call does nothing 597  * and returns `false`. 598  * 599  * @param sessionId [`SessionId`][SessionId]. 600  * @param event [`Event`][Event]. 601  * 602  * @return flag whether the event was consumed (meaning moving to a new state) or ignored. 603  * 604  * @throws [RuntimeException] for a strict [`mode`][mode], if `SessionTracker` has not been initialized. 605  * For a relaxed [`mode`][mode] it just logs an error message and returns false. 606  * @throws [RuntimeException] for a strict [`mode`][mode], if this call is initiated from the 607  * [`sessionTrackerStorage`][sessionTrackerStorage]. For a relaxed [`mode`][mode] it just logs an error message 608  * and returns false. 609  */ 610  @Synchronized 611  @JvmName("consumeEvent") 612  fun consumeEvent(sessionId: SessionId, event: Event): Boolean { 613  if (!ensureInitialized("consumeEvent")) { 614  return false 615  } 616  if (mode.verbose) { 617  logger.d(logTag, "consumeEvent: sessionId = '${sessionId.value}', event = '$event'") 618  } 619  if (!ensureNotPersisting("consumeEvent")) { 620  return false 621  } 622  val sessionInfo = sessionsMap[sessionId] 623  if (sessionInfo == null) { 624  logger.w(logTag, "consumeEvent: no session with ID '${sessionId.value}' found") 625  } else { 626  if (sessionInfo.isUntracking) { 627  logger.w(logTag, "consumeEvent: event = '$event', session with ID '${sessionId.value}' is already untracking") 628  } else if (sessionInfo.stateMachine.consumeEvent(event)) { 629  return true 630  } 631  if (mode.verbose) { 632  logger.d( 633  logTag, "consumeEvent: event '$event' was ignored for session with ID '${sessionId.value}' " + 634  "in state ${sessionInfo.stateMachine.getCurrentState()}, " + 635  "isUntracking = ${sessionInfo.isUntracking}" 636  ) 637  } 638  } 639  return false 640  } 641  642  private fun doUntrackSession(sessionId: SessionId, stateMachine: StateMachine<Event, State>) { 643  stateMachine.removeAllListeners() 644  doPersistAction { sessionTrackerStorage.deleteSessionRecord(sessionId) } 645  sessionsMap.remove(sessionId) 646  listener!!.onSessionTrackingStopped(this@SessionTracker, SessionRecord(sessionId, stateMachine.getCurrentState())) 647  } 648  649  private fun ensureInitialized(method: String): Boolean { 650  if (!initialized) { 651  val explanation = "$logTag must be initialized before calling its #$method method" 652  if (mode.strict) { 653  throw RuntimeException(explanation) 654  } else { 655  logger.e(logTag, explanation) 656  } 657  } 658  return initialized 659  } 660  661  private fun ensureNotPersisting(method: String): Boolean { 662  if (persisting) { 663  val explanation = "$method: misuse detected, accessing " + 664  "$logTag from ${ISessionTrackerStorage::class.java.simpleName} callbacks is not allowed" 665  if (mode.strict) { 666  throw RuntimeException(explanation) 667  } else { 668  logger.e(logTag, explanation) 669  } 670  } 671  return !persisting 672  } 673  674  private fun setupSessionStateMachine(sessionRecord: SessionRecord<State>): StateMachine<Event, State> { 675  val (sessionId, state) = sessionRecord 676  677  val builder = StateMachine.Builder<Event, State>().setInitialState(state) 678  679  sessionStateTransitionsSupplier.getStateTransitions(sessionId) 680  .forEach { transition -> 681  builder.addTransition( 682  StateMachine.Transition(transition.event, transition.statePath) 683  ) 684  } 685  686  val stateMachine = builder.build() 687  688  stateMachine.addListener(object : StateMachine.Listener<State> { 689  override fun onStateChanged(oldState: State, newState: State) { 690  val baseLogMessage = "onStateChanged: '$oldState' -> '$newState', sessionId = '${sessionId.value}'" 691  692  val sessionInfo = sessionsMap[sessionId] 693  694  checkNotNull(sessionInfo) { "$baseLogMessage - session not found" } 695  check(sessionInfo.isUntracking.not()) { "$baseLogMessage - session is untracking" } 696  697  if (mode.verbose) { 698  logger.d(logTag, baseLogMessage) 699  } 700  701  val updatedSessionRecord = SessionRecord(sessionId, newState) 702  703  if (newState in autoUntrackStates) { 704  logger.d(logTag, "$baseLogMessage, going to auto-untrack session..") 705  val updatedSessionInfo = sessionInfo.copy(isUntracking = true) 706  sessionsMap[sessionId] = updatedSessionInfo 707  stateMachine.removeAllListeners() 708  listener!!.onSessionStateChanged(this@SessionTracker, updatedSessionRecord, oldState) 709  if (sessionsMap.containsKey(sessionId)) { 710  doUntrackSession(sessionId, updatedSessionInfo.stateMachine) 711  } 712  } else { 713  doPersistAction { sessionTrackerStorage.updateSessionRecord(updatedSessionRecord) } 714  listener!!.onSessionStateChanged(this@SessionTracker, updatedSessionRecord, oldState) 715  } 716  } 717  }) 718  719  return stateMachine 720  } 721  722  private fun doPersistAction(action: () -> Unit) { 723  persisting = true 724  try { 725  action.invoke() 726  } finally { 727  persisting = false 728  } 729  } 730  731  private data class SessionInfo<Event : Enum<Event>, State : Enum<State>>( 732  val stateMachine: StateMachine<Event, State>, 733  val isUntracking: Boolean = false 734  ) 735 }