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 }