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 }