Skip to content

Commit

Permalink
Add an option to continue previously persisted session when the app r…
Browse files Browse the repository at this point in the history
…estarts rather than starting a new one (close #340)
  • Loading branch information
matus-tomlein committed Feb 10, 2025
1 parent 5cf2974 commit 601d947
Show file tree
Hide file tree
Showing 13 changed files with 271 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,10 @@ class Demo : Activity(), LoggerDelegate {
.installAutotracking(true)
.diagnosticAutotracking(true)
val sessionConfiguration = SessionConfiguration(
TimeMeasure(6, TimeUnit.SECONDS),
TimeMeasure(30, TimeUnit.SECONDS)
TimeMeasure(30, TimeUnit.MINUTES),
TimeMeasure(30, TimeUnit.MINUTES)
)
.continueSessionOnRestart(true)
.onSessionUpdate { state: SessionState ->
updateLogger(
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class ConfigurationTest {
Assert.assertEquals(protocol, scheme)
Assert.assertEquals(trackerConfiguration.appId, tracker.appId)
Assert.assertEquals("namespace", tracker.namespace)
Assert.assertEquals(tracker.session!!.continueSessionOnRestart, false)
}

@Test
Expand All @@ -83,12 +84,14 @@ class ConfigurationTest {
val networkConfig = NetworkConfiguration("fake-url", HttpMethod.POST)
val trackerConfig = TrackerConfiguration("appId")
val sessionConfig = SessionConfiguration(expectedForeground, expectedBackground)
sessionConfig.continueSessionOnRestart = true
val tracker =
createTracker(context, "namespace", networkConfig, trackerConfig, sessionConfig)
val foreground = tracker.session!!.foregroundTimeout
val background = tracker.session!!.backgroundTimeout
Assert.assertEquals(expectedForeground, foreground)
Assert.assertEquals(expectedBackground, background)
Assert.assertEquals(tracker.session!!.continueSessionOnRestart, true)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ class SessionTest {
Assert.assertEquals(300000, session.backgroundTimeout)
Assert.assertNull(sessionState)
Assert.assertNotNull(session.userId)
val sdj = session.getSessionContext("first-id-1", timestamp, false)
val sdj = session.getAndUpdateSessionForEvent("first-id-1", timestamp, false)
sessionState = session.state

Assert.assertNotNull(sdj)
Assert.assertNotNull(sessionState)
Assert.assertEquals("first-id-1", sessionState!!.firstEventId)
Assert.assertEquals(timestampDateTime, sessionState.firstEventTimestamp)
session.getSessionContext("second-id-2", timestamp + 10000, false)
session.getAndUpdateSessionForEvent("second-id-2", timestamp + 10000, false)
Assert.assertEquals("first-id-1", sessionState.firstEventId)
Assert.assertEquals(timestampDateTime, sessionState.firstEventTimestamp)
Assert.assertEquals(TrackerConstants.SESSION_SCHEMA, sdj!!.map["schema"])
Expand Down Expand Up @@ -342,21 +342,21 @@ class SessionTest {
val tracker2 = Tracker(emitter, "tracker2", "app", context = context, builder = trackerBuilder)
val session1 = tracker1.session
val session2 = tracker2.session
session1!!.getSessionContext("session1-fake-id1", timestamp, false)
session2!!.getSessionContext("session2-fake-id1", timestamp, false)
session1!!.getAndUpdateSessionForEvent("session1-fake-id1", timestamp, false)
session2!!.getAndUpdateSessionForEvent("session2-fake-id1", timestamp, false)
val initialValue1 = session1.sessionIndex?.toLong()
val id1 = session1.state!!.sessionId
val initialValue2 = session2.sessionIndex?.toLong()

// Retrigger session in tracker1
// The timeout is 20s, this sleep is only 2s - it's still the same session
Thread.sleep(2000)
session1.getSessionContext("session1-fake-id2", timestamp, false)
session1.getAndUpdateSessionForEvent("session1-fake-id2", timestamp, false)

// Retrigger timedout session in tracker2
// 20s has then passed. Session must be updated, increasing the sessionIndex by 1
Thread.sleep(18000)
session2.getSessionContext("session2-fake-id2", timestamp, false)
session2.getAndUpdateSessionForEvent("session2-fake-id2", timestamp, false)

// Check sessions have the correct state
Assert.assertEquals(0, session1.sessionIndex!! - initialValue1!!)
Expand All @@ -365,7 +365,7 @@ class SessionTest {

// Recreate tracker2
val tracker2b = Tracker(emitter, "tracker2", "app", context = context, builder = trackerBuilder)
tracker2b.session!!.getSessionContext("session2b-fake-id3", timestamp, false)
tracker2b.session!!.getAndUpdateSessionForEvent("session2b-fake-id3", timestamp, false)
val initialValue2b = tracker2b.session!!.sessionIndex?.toLong()
val previousId2b = tracker2b.session!!.state!!.previousSessionId

Expand All @@ -388,6 +388,47 @@ class SessionTest {
Assert.assertNull(context[Parameters.SESSION_PREVIOUS_ID])
}

@Test
fun testStartsNewSessionOnRestartByDefault() {
val session1 = Session(foregroundTimeout = 3, backgroundTimeout = 3, namespace = "t1", timeUnit = TimeUnit.SECONDS, context = context)
val firstSession = session1.getAndUpdateSessionForEvent("event_1", eventTimestamp = 1654496481345, userAnonymisation = false)

val session2 = Session(foregroundTimeout = 3, backgroundTimeout = 3, namespace = "t1", timeUnit = TimeUnit.SECONDS, context = context)
val secondSession = session2.getAndUpdateSessionForEvent("event_2", eventTimestamp = 1654496481345, userAnonymisation = false)

Assert.assertNotNull(firstSession?.sessionId)
Assert.assertNotEquals(firstSession?.sessionId, secondSession?.sessionId)
Assert.assertEquals(firstSession?.sessionId, secondSession?.previousSessionId)
}

@Test
fun testResumesPreviouslyPersistedSessionIfEnabled() {
val session1 = Session(foregroundTimeout = 3, backgroundTimeout = 3, namespace = "t1", continueSessionOnRestart = true, timeUnit = TimeUnit.SECONDS, context = context)
session1.getAndUpdateSessionForEvent("event_1", eventTimestamp = 1654496481345, userAnonymisation = false)
val firstSession = session1.getAndUpdateSessionForEvent("event_2", eventTimestamp = 1654496481346, userAnonymisation = false)

val session2 = Session(foregroundTimeout = 3, backgroundTimeout = 3, namespace = "t1", continueSessionOnRestart = true, timeUnit = TimeUnit.SECONDS, context = context)
val secondSession = session2.getAndUpdateSessionForEvent("event_3", eventTimestamp = 1654496481347, userAnonymisation = false)

Assert.assertNotNull(firstSession?.sessionId)
Assert.assertEquals(firstSession?.sessionId, secondSession?.sessionId)
Assert.assertEquals(secondSession?.eventIndex, 3)
}

@Test
fun testStartsNewSessionOnRestartOnTimeout() {
val session1 = Session(foregroundTimeout = 100, backgroundTimeout = 100, namespace = "t1", continueSessionOnRestart = true, timeUnit = TimeUnit.MILLISECONDS, context = context)
val firstSession = session1.getAndUpdateSessionForEvent("event_1", eventTimestamp = 1654496481345, userAnonymisation = false)

Thread.sleep(500)

val session2 = Session(foregroundTimeout = 100, backgroundTimeout = 100, namespace = "t1", continueSessionOnRestart = true, timeUnit = TimeUnit.MILLISECONDS, context = context)
val secondSession = session2.getAndUpdateSessionForEvent("event_2", eventTimestamp = 1654496481345, userAnonymisation = false)

Assert.assertNotEquals(firstSession?.sessionId, secondSession?.sessionId)
Assert.assertEquals(firstSession?.sessionId, secondSession?.previousSessionId)
}

// Private methods
private fun getSession(foregroundTimeout: Long, backgroundTimeout: Long): Session {
context.getSharedPreferences(TrackerConstants.SNOWPLOW_SESSION_VARS, Context.MODE_PRIVATE)
Expand All @@ -403,7 +444,7 @@ class SessionTest {
eventTimestamp: Long,
userAnonymisation: Boolean
): Map<String, Any>? {
return session!!.getSessionContext(
return session!!.getAndUpdateSessionForEvent(
eventId,
eventTimestamp,
userAnonymisation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ object Parameters {
const val SESSION_STORAGE = "storageMechanism"
const val SESSION_FIRST_ID = "firstEventId"
const val SESSION_FIRST_TIMESTAMP = "firstEventTimestamp"
const val SESSION_LAST_UPDATE = "lastUpdate"

// Screen Context
const val SCREEN_NAME = "name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@ import androidx.core.util.Consumer
import com.snowplowanalytics.core.constants.Parameters
import com.snowplowanalytics.core.constants.TrackerConstants
import com.snowplowanalytics.core.tracker.Logger
import com.snowplowanalytics.core.tracker.TrackerDefaults
import com.snowplowanalytics.core.utils.Util
import com.snowplowanalytics.snowplow.entity.ClientSessionEntity
import com.snowplowanalytics.snowplow.payload.SelfDescribingJson
import com.snowplowanalytics.snowplow.tracker.SessionState
import com.snowplowanalytics.snowplow.tracker.SessionState.Companion.build
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.TimeUnit
Expand All @@ -48,12 +47,14 @@ class Session @SuppressLint("ApplySharedPref") constructor(
backgroundTimeout: Long,
timeUnit: TimeUnit,
namespace: String?,
context: Context
context: Context,

/// If enabled, will persist all session updates (also changes to eventIndex) and will be able to continue the previous session when the app is closed and reopened.
var continueSessionOnRestart: Boolean = TrackerDefaults.continueSessionOnRestart,
) {
// Session Variables
var userId: String
private set
private var eventIndex = 0

@Volatile
var backgroundIndex = 0
Expand All @@ -69,7 +70,6 @@ class Session @SuppressLint("ApplySharedPref") constructor(
val isBackground: Boolean
get() = _isBackground.get()

private var lastSessionCheck: Long = 0
private val isNewSession = AtomicBoolean(true)

@Volatile
Expand All @@ -92,6 +92,8 @@ class Session @SuppressLint("ApplySharedPref") constructor(
this.foregroundTimeout = timeUnit.toMillis(foregroundTimeout)
this.backgroundTimeout = timeUnit.toMillis(backgroundTimeout)
isSessionCheckerEnabled = true

isNewSession.set(!continueSessionOnRestart)

var sessionVarsName = TrackerConstants.SNOWPLOW_SESSION_VARS
if (namespace != null && namespace.isNotEmpty()) {
Expand All @@ -105,11 +107,10 @@ class Session @SuppressLint("ApplySharedPref") constructor(
if (sessionInfo == null) {
Logger.track(TAG, "No previous session info available")
} else {
state = build(sessionInfo)
state = SessionState.build(sessionInfo)
}
userId = retrieveUserId(context, state)
sharedPreferences = context.getSharedPreferences(sessionVarsName, Context.MODE_PRIVATE)
lastSessionCheck = System.currentTimeMillis()
} finally {
StrictMode.setThreadPolicy(oldPolicy)
}
Expand All @@ -122,34 +123,40 @@ class Session @SuppressLint("ApplySharedPref") constructor(
* @return a SelfDescribingJson containing the session context
*/
@Synchronized
fun getSessionContext(
fun getAndUpdateSessionForEvent(
eventId: String,
eventTimestamp: Long,
userAnonymisation: Boolean
): SelfDescribingJson? {
): ClientSessionEntity? {
Logger.v(TAG, "Getting session context...")
if (isSessionCheckerEnabled) {
if (shouldUpdateSession()) {
Logger.d(TAG, "Update session information.")
updateSession(eventId, eventTimestamp)
if (isBackground) { // timed out in background
executeEventCallback(backgroundTimeoutCallback)
} else { // timed out in foreground
executeEventCallback(foregroundTimeoutCallback)
}
if (isSessionCheckerEnabled && shouldStartNewSession()) {
Logger.d(TAG, "Update session information.")
startNewSession(eventId, eventTimestamp)

state?.let {
callOnSessionUpdateCallback(it)
}

if (isBackground) { // timed out in background
executeEventCallback(backgroundTimeoutCallback)
} else { // timed out in foreground
executeEventCallback(foregroundTimeoutCallback)
}
lastSessionCheck = System.currentTimeMillis()

if (!continueSessionOnRestart) { persist() }
}
eventIndex += 1


val state = state ?: run {
Logger.v(TAG, "Session state not present")
return null
}

state.updateForNextEvent(isSessionCheckerEnabled)
// persist every session update
if (continueSessionOnRestart) { persist() }

val sessionValues = state.sessionValues
val sessionCopy: MutableMap<String, Any?> = HashMap(sessionValues)
sessionCopy[Parameters.SESSION_EVENT_INDEX] = eventIndex
if (userAnonymisation) {
sessionCopy[Parameters.SESSION_USER_ID] =
"00000000-0000-0000-0000-000000000000"
Expand All @@ -158,51 +165,48 @@ class Session @SuppressLint("ApplySharedPref") constructor(
return ClientSessionEntity(sessionCopy)
}

private fun shouldUpdateSession(): Boolean {
private fun shouldStartNewSession(): Boolean {
if (isNewSession.get()) {
return true
}
val now = System.currentTimeMillis()
val timeout = if (isBackground) backgroundTimeout else foregroundTimeout
return now < lastSessionCheck || now - lastSessionCheck > timeout
val lastSessionCheck = state?.lastUpdate
if (lastSessionCheck !== null) {
val now = System.currentTimeMillis()
val timeout = if (isBackground) backgroundTimeout else foregroundTimeout
return now < lastSessionCheck || now - lastSessionCheck > timeout
}
return true
}

@Synchronized
private fun updateSession(eventId: String, eventTimestamp: Long) {
private fun startNewSession(eventId: String, eventTimestamp: Long) {
isNewSession.set(false)
val currentSessionId = Util.uUIDString()
val eventTimestampDateTime = Util.getDateTimeFromTimestamp(eventTimestamp)

var sessionIndex = 1
eventIndex = 0
var previousSessionId: String? = null
var storage = "LOCAL_STORAGE"
state?.let {
sessionIndex = it.sessionIndex + 1
previousSessionId = it.sessionId
storage = it.storage
}
state = SessionState(
eventId,
eventTimestampDateTime,
currentSessionId,
previousSessionId,
sessionIndex,
userId,
storage
)
state?.let {
storeSessionState(it)
callOnSessionUpdateCallback(it)

if (state == null) {
state = SessionState(
firstEventId = eventId,
firstEventTimestamp = Util.getDateTimeFromTimestamp(eventTimestamp),
sessionId = Util.uUIDString(),
previousSessionId = null,
sessionIndex = 1,
userId = userId,
)
} else {
state?.startNewSession(
eventId = eventId,
eventTimestamp = eventTimestamp
)
}
}

private fun storeSessionState(state: SessionState) {
val jsonObject = JSONObject(state.sessionValues)
val jsonString = jsonObject.toString()
val editor = sharedPreferences.edit()
editor.putString(TrackerConstants.SESSION_STATE, jsonString)
editor.apply()
private fun persist() {
state?.let { state ->
val jsonObject = JSONObject(state.dataToPersist)
val jsonString = jsonObject.toString()
val editor = sharedPreferences.edit()
editor.putString(TrackerConstants.SESSION_STATE, jsonString)
editor.apply()
}
}

private fun callOnSessionUpdateCallback(state: SessionState) {
Expand Down Expand Up @@ -324,10 +328,18 @@ class Session @SuppressLint("ApplySharedPref") constructor(
backgroundTimeout: Long,
timeUnit: TimeUnit,
namespace: String?,
sessionCallbacks: Array<Runnable?>?
sessionCallbacks: Array<Runnable?>?,
continueSessionOnRestart: Boolean
): Session {
val session =
Session(foregroundTimeout, backgroundTimeout, timeUnit, namespace, context)
Session(
foregroundTimeout = foregroundTimeout,
backgroundTimeout = backgroundTimeout,
timeUnit = timeUnit,
namespace = namespace,
context = context,
continueSessionOnRestart = continueSessionOnRestart
)
var callbacks: Array<Runnable?>? = arrayOf(null, null, null, null)
if (sessionCallbacks != null && sessionCallbacks.size == 4) {
callbacks = sessionCallbacks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,12 @@ interface SessionConfigurationInterface {
* The callback called every time the session is updated.
*/
var onSessionUpdate: Consumer<SessionState>?


/**
* If enabled, will be able to continue the previous session when the app is closed and reopened (if it doesn't timeout).
* Disabled by default, which means that every restart of the app starts a new session.
* When enabled, every event will result in the session being updated in the UserDefaults.
*/
var continueSessionOnRestart: Boolean
}
Loading

0 comments on commit 601d947

Please sign in to comment.