diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md new file mode 100644 index 00000000..a6434a8c --- /dev/null +++ b/.changeset/gentle-clouds-drift.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Enforce 24-hour maximum session duration with automatic session rotation diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index 2505fb13..119f4f8f 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -107,8 +107,10 @@ public class PostHogAndroid private constructor() { val dateProvider = PostHogAndroidDateProvider() config.dateProvider = dateProvider TimeBasedEpochGenerator.setDateProvider(dateProvider) + PostHogSessionManager.setDateProvider(dateProvider) } else { TimeBasedEpochGenerator.setDateProvider(config.dateProvider) + PostHogSessionManager.setDateProvider(config.dateProvider) } } config.networkStatus = config.networkStatus ?: PostHogAndroidNetworkStatus(context) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt index 7fd4d5f7..ec778a25 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt @@ -8,8 +8,10 @@ import androidx.lifecycle.ProcessLifecycleOwner import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.PostHogSessionManager import java.util.Timer import java.util.TimerTask +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong /** @@ -28,6 +30,7 @@ internal class PostHogLifecycleObserverIntegration( private var timer = Timer(true) private var timerTask: TimerTask? = null private val lastUpdatedSession = AtomicLong(0L) + private val replayActiveBeforeRotation = AtomicBoolean(false) private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes private var postHog: PostHogInterface? = null @@ -44,6 +47,9 @@ internal class PostHogLifecycleObserverIntegration( } override fun onStart(owner: LifecycleOwner) { + PostHogSessionManager.setAppInBackground(false) + // Foregrounding counts as activity (mirror iOS onDidBecomeActive). + PostHogSessionManager.touchSession() startSession() if (config.captureApplicationLifecycleEvents) { @@ -73,6 +79,22 @@ internal class PostHogLifecycleObserverIntegration( (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis ) { postHog?.startSession() + // If the previous session was ended via 24h rotation in onStop, + // restart replay so it continues under the new session + if (replayActiveBeforeRotation.compareAndSet(true, false)) { + postHog?.startSessionReplay(resumeCurrent = true) + } + } else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { + // Session has been active for longer than 24 hours, rotate to a new session + if (postHog?.isSessionReplayActive() == true) { + postHog?.stopSessionReplay() + + // startSessionReplay will rotate the session id internally + postHog?.startSessionReplay(resumeCurrent = false) + } else { + postHog?.endSession() + postHog?.startSession() + } } this.lastUpdatedSession.set(currentTimeMillis) } @@ -85,6 +107,12 @@ internal class PostHogLifecycleObserverIntegration( } private fun scheduleEndSession() { + // This timer honors the PostHogInterface.endSession docstring promise: + // "On Android, the SDK will automatically end a session when the app is + // in the background for at least 30 minutes." The getter's inactivity + // check isn't sufficient on its own because isSessionActive() reads the + // field directly — without this timer, a backgrounded app that fires no + // events would keep reporting an active session forever. synchronized(timerLock) { cancelTask() timerTask = @@ -98,14 +126,31 @@ internal class PostHogLifecycleObserverIntegration( } override fun onStop(owner: LifecycleOwner) { + val currentTimeMillis = config.dateProvider.currentTimeMillis() + // Snapshot before flipping the bg flag: once we set it, the next getActiveSessionId + // (e.g., while capturing "Application Backgrounded") may clear an expired session, + // zeroing sessionStartedAt so the 24h check below would miss it. + val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis) + + PostHogSessionManager.setAppInBackground(true) if (config.captureApplicationLifecycleEvents) { postHog?.capture("Application Backgrounded") } postHog?.flush() - val currentTimeMillis = config.dateProvider.currentTimeMillis() - lastUpdatedSession.set(currentTimeMillis) - scheduleEndSession() + // Session has been active for longer than 24 hours, rotate to a new session + if (wasExpired) { + cancelTask() + val wasReplayActive = postHog?.isSessionReplayActive() == true + postHog?.endSession() + postHog?.stopSessionReplay() + replayActiveBeforeRotation.set(wasReplayActive) + // Reset so the next onStart knows to create a fresh session + lastUpdatedSession.set(0L) + } else { + lastUpdatedSession.set(currentTimeMillis) + scheduleEndSession() + } } private fun add() { diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 54a8170b..8e23b904 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -276,6 +276,8 @@ public class PostHogReplayIntegration( private val onTouchEventListener = TouchEventInterceptor { motionEvent, dispatch -> val timestamp = config.dateProvider.currentTimeMillis() + // User touch counts as activity (closest equivalent to iOS UIEvent swizzling). + PostHogSessionManager.touchSession() try { val state = dispatch(motionEvent) @@ -1662,7 +1664,8 @@ public class PostHogReplayIntegration( /** * Called when the session ID changes. Stops recording if event triggers are configured - * and the new session hasn't been activated yet. + * and the new session hasn't been activated yet, or re-initializes recording so the + * new session gets fresh meta + full wireframe events. */ override fun onSessionIdChanged() { val postHog = this.postHog ?: return @@ -1678,6 +1681,20 @@ public class PostHogReplayIntegration( config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.") stop() } + } else if (isSessionReplayActive) { + // Session rotated/cleared silently (e.g., 24h max duration via getter). + // Posting to main: getter can be invoked from any thread that calls capture(), + // and start(resumeCurrent = false) iterates a non-thread-safe WeakHashMap. + if (currentSessionId == null) { + config.logger.log("[Session Replay] Session cleared. Stopping recording.") + mainHandler.handler.post { stop() } + } else { + config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") + mainHandler.handler.post { + stop() + start(resumeCurrent = false) + } + } } } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 1960d808..948b7988 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -4,6 +4,7 @@ import com.posthog.FeatureFlagResult import com.posthog.PostHogConfig import com.posthog.PostHogInterface import com.posthog.PostHogOnFeatureFlags +import com.posthog.internal.PostHogSessionManager import java.util.Date import java.util.UUID @@ -13,6 +14,9 @@ public class PostHogFake : PostHogInterface { public var properties: Map? = null public var captures: Int = 0 public var flushes: Int = 0 + public var sessionReplayActive: Boolean = false + public var startSessionReplayCalls: Int = 0 + public var stopSessionReplayCalls: Int = 0 override fun setup(config: T) { } @@ -179,23 +183,29 @@ public class PostHogFake : PostHogInterface { } override fun startSession() { + PostHogSessionManager.startSession() } override fun endSession() { + PostHogSessionManager.endSession() } override fun isSessionActive(): Boolean { - return false + return PostHogSessionManager.isSessionActive() } override fun isSessionReplayActive(): Boolean { - return false + return sessionReplayActive } override fun startSessionReplay(resumeCurrent: Boolean) { + startSessionReplayCalls++ + sessionReplayActive = true } override fun stopSessionReplay() { + stopSessionReplayCalls++ + sessionReplayActive = false } override fun getSessionId(): UUID? { diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt index b5b9779c..ea1f4865 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt @@ -10,11 +10,19 @@ import com.posthog.android.FakeLifecycle import com.posthog.android.PostHogAndroidConfig import com.posthog.android.createPostHogFake import com.posthog.android.mockPackageInfo +import com.posthog.internal.PostHogDateProvider +import com.posthog.internal.PostHogDeviceDateProvider +import com.posthog.internal.PostHogSessionManager import org.junit.runner.RunWith import org.mockito.kotlin.mock +import java.util.Calendar +import java.util.Date +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull @RunWith(AndroidJUnit4::class) internal class PostHogLifecycleObserverIntegrationTest { @@ -30,6 +38,14 @@ internal class PostHogLifecycleObserverIntegrationTest { @BeforeTest fun `set up`() { PostHog.resetSharedInstance() + PostHogSessionManager.endSession() + } + + @AfterTest + fun `tear down`() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.setDateProvider(PostHogDeviceDateProvider()) + PostHogSessionManager.endSession() } @Test @@ -139,4 +155,226 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.uninstall() } + + @Test + fun `onStart rotates session when session exceeds 24 hours`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + sut.install(fake) + + // Start a session (simulates first app open) + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + // First onStart at current time - this sets lastUpdatedSession + sut.onStart(ProcessLifecycleOwner.get()) + + // Simulate app going to background and coming back within 30 min interval + // but the total session duration exceeds 24 hours. + // We advance time by 25 minutes (within 30 min interval) repeatedly + // to simulate many short background/foreground cycles over 24+ hours. + // For the test, we just advance the clock by 24h+1min but keep lastUpdatedSession recent + // by doing a stop/start cycle at 24h+1min - 10min, then at 24h+1min + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val tenMinutesMs = 1000L * 60 * 10 + val oneMinuteMs = 1000L * 60 + + // Advance to 24h - 10 min (session still under 24h, within 30 min interval doesn't matter + // since we're simulating continuous use) + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs - tenMinutesMs + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) // updates lastUpdatedSession + + // Now advance to 24h + 1 min (11 min after last update, within 30 min interval) + // Session started at baseTime, so it's now > 24 hours old + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + // Session should have been rotated + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + assertNotEquals(firstSessionId, secondSessionId) + + sut.uninstall() + } + + @Test + fun `onStart restarts session replay after 24h rotation in background when replay was active`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + fake.sessionReplayActive = true + sut.install(fake) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + sut.onStart(ProcessLifecycleOwner.get()) + + // Advance past 24h and background the app - triggers rotation in onStop + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val oneMinuteMs = 1000L * 60 + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + sut.onStop(ProcessLifecycleOwner.get()) + + // After rotation in onStop: session ended, replay stopped + assertEquals(1, fake.stopSessionReplayCalls) + assertEquals(false, fake.sessionReplayActive) + + // User returns; a new session is created and replay should resume + sut.onStart(ProcessLifecycleOwner.get()) + + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + assertNotEquals(firstSessionId, secondSessionId) + assertEquals(1, fake.startSessionReplayCalls) + assertEquals(true, fake.sessionReplayActive) + + sut.uninstall() + } + + @Test + fun `onStart does not restart session replay after 24h rotation when replay was inactive`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + // replay was never active + sut.install(fake) + + PostHogSessionManager.startSession() + sut.onStart(ProcessLifecycleOwner.get()) + + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val oneMinuteMs = 1000L * 60 + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + assertEquals(0, fake.startSessionReplayCalls) + assertEquals(false, fake.sessionReplayActive) + + sut.uninstall() + } + + @Test + fun `onStart does not rotate session when session is under 24 hours`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + sut.install(fake) + + // Start a session + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + // First onStart + sut.onStart(ProcessLifecycleOwner.get()) + + // Simulate returning within 5 minutes (well within both 30 min and 24 hour limits) + val fiveMinutesMs = 1000L * 60 * 5 + fakeDateProvider.currentTimeMs = baseTime + fiveMinutesMs + + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + // Session should NOT have been rotated + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertEquals(firstSessionId, secondSessionId) + + sut.uninstall() + } + + @Test + fun `onStart does not rotate session when React Native even if session exceeds 24 hours`() { + PostHogSessionManager.isReactNative = true + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + sut.install(fake) + + // RN sets its own session id + val sessionId = java.util.UUID.randomUUID() + PostHogSessionManager.setSessionId(sessionId) + + // First onStart + sut.onStart(ProcessLifecycleOwner.get()) + + // Advance past 24 hours + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val oneMinuteMs = 1000L * 60 + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + // Session should NOT have been rotated since RN manages its own session + assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) + + sut.uninstall() + } + + /** + * A simple fake date provider for testing time-dependent behavior. + */ + private class FakeDateProviderForTest(initialTimeMs: Long = System.currentTimeMillis()) : PostHogDateProvider { + var currentTimeMs: Long = initialTimeMs + + override fun currentDate(): Date = Date(currentTimeMs) + + override fun addSecondsToCurrentDate(seconds: Int): Date { + val cal = Calendar.getInstance() + cal.timeInMillis = currentTimeMs + cal.add(Calendar.SECOND, seconds) + return cal.time + } + + override fun currentTimeMillis(): Long = currentTimeMs + + override fun nanoTime(): Long = System.nanoTime() + } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt index eacc579a..a061438f 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt @@ -41,8 +41,8 @@ class MyApp : Application() { sessionReplayConfig.maskAllImages = false sessionReplayConfig.captureLogcat = true sessionReplayConfig.screenshot = true - surveys = true - errorTrackingConfig.autoCapture = true + surveys = false + errorTrackingConfig.autoCapture = false } PostHogAndroid.setup(this, config) } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index d633c274..4af8c404 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -889,11 +889,17 @@ public final class com/posthog/internal/PostHogSessionManager { public static final field INSTANCE Lcom/posthog/internal/PostHogSessionManager; public final fun endSession ()V public final fun getActiveSessionId ()Ljava/util/UUID; + public final fun getSessionStartedAt ()J public final fun isReactNative ()Z public final fun isSessionActive ()Z + public final fun isSessionExceedingMaxDuration (J)Z + public final fun setAppInBackground (Z)V + public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V + public final fun setOnSessionIdChangedListener (Lkotlin/jvm/functions/Function0;)V public final fun setReactNative (Z)V public final fun setSessionId (Ljava/util/UUID;)V public final fun startSession ()V + public final fun touchSession ()V } public final class com/posthog/internal/PostHogThreadFactory : java/util/concurrent/ThreadFactory { diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 82c6a68c..41b565b2 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -190,6 +190,10 @@ public class PostHog private constructor( queue.start() + PostHogSessionManager.setOnSessionIdChangedListener { + sessionReplayHandler?.onSessionIdChanged() + } + startSession() config.integrations.forEach { @@ -308,6 +312,8 @@ public class PostHog private constructor( featureFlagsCalled.clear() + PostHogSessionManager.setOnSessionIdChangedListener(null) + endSession() } catch (e: Throwable) { config?.logger?.log("Close failed: $e.") @@ -433,8 +439,14 @@ public class PostHog private constructor( val isSessionReplayActive = isSessionReplayActive() - PostHogSessionManager.getActiveSessionId()?.let { sessionId -> - val tempSessionId = sessionId.toString() + // Skip the getter when caller pre-attached an id: getActiveSessionId() can + // silently rotate, and the caller's value wins via putAll either way. + val propSessionId = properties?.get("\$session_id") as? String + val sessionIdString = + propSessionId?.takeIf { it.isNotBlank() } + ?: PostHogSessionManager.getActiveSessionId()?.toString() + + sessionIdString?.let { tempSessionId -> props["\$session_id"] = tempSessionId // only Session replay needs $window_id if (!appendSharedProps && isSessionReplayActive) { @@ -476,6 +488,10 @@ public class PostHog private constructor( config?.logger?.log("PostHog is in OptOut state.") return } + // Mark activity before reading session id. iOS achieves this via UIEvent + // swizzling; Android lacks a global equivalent so any capture counts as + // activity and may rotate the session if it has gone idle for 30min. + PostHogSessionManager.touchSession() val newDistinctId = distinctId ?: this.distinctId diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 4f0ee1ee..88e6e844 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -1,6 +1,7 @@ package com.posthog.internal import com.posthog.PostHogInternal +import com.posthog.PostHogVisibleForTesting import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.UUID @@ -16,45 +17,176 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone + private var dateProvider: PostHogDateProvider? = null + + public fun setDateProvider(dateProvider: PostHogDateProvider) { + this.dateProvider = dateProvider + } + + /** + * Timestamp (in milliseconds) when the current session was started. + * Reset to 0 when the session ends. + */ + private var sessionStartedAt: Long = 0L + + /** + * Timestamp (in milliseconds) of the last user activity on the current session. + * Used to detect 30-minute inactivity rotation. Reset to 0 when the session ends. + */ + private var sessionActivityTimestamp: Long = 0L + @Volatile public var isReactNative: Boolean = false + @Volatile + private var isAppInBackground: Boolean = false + + @Volatile + private var onSessionIdChangedListener: (() -> Unit)? = null + + /** + * Update the foreground/background state. Set from lifecycle callbacks to control + * whether an expired session rotates (foreground) or is cleared (background) on read. + */ + public fun setAppInBackground(inBackground: Boolean) { + isAppInBackground = inBackground + } + + /** + * Registered by PostHog.setup; invoked after getActiveSessionId rotates the session + * silently, so the session replay handler can react to the change. + */ + public fun setOnSessionIdChangedListener(listener: (() -> Unit)?) { + onSessionIdChangedListener = listener + } + public fun startSession() { - if (isReactNative) { - // RN manages its own session - return + synchronized(sessionLock) { + // Re-check inside the lock — RN flag is set once at setup but checking + // here keeps state consistent with the lock's invariants. + if (isReactNative || sessionId != sessionIdNone) return + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = now + sessionActivityTimestamp = now } + } + public fun endSession() { synchronized(sessionLock) { - if (sessionId == sessionIdNone) { - sessionId = TimeBasedEpochGenerator.generate() - } + if (isReactNative) return + clearLocked() } } - public fun endSession() { - if (isReactNative) { - // RN manages its own session - return + /** + * Returns the timestamp (in milliseconds) when the current session was started, + * or 0 if no session is active. + */ + @PostHogVisibleForTesting + public fun getSessionStartedAt(): Long { + synchronized(sessionLock) { + return sessionStartedAt } + } + /** + * Returns true if the current session has been active for longer than 24 hours. + */ + public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { synchronized(sessionLock) { - sessionId = sessionIdNone + return isMaxExpired(currentTimeMillis) } } + private const val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours + private const val SESSION_INACTIVITY_DURATION = (1000L * 60 * 30) // 30 minutes + + // Both helpers must be called while holding sessionLock — they read mutable fields + // without taking the lock themselves to avoid nested-lock complexity. + private fun isIdle(now: Long): Boolean = + sessionActivityTimestamp > 0L && + (sessionActivityTimestamp + SESSION_INACTIVITY_DURATION) <= now + + private fun isMaxExpired(now: Long): Boolean = + sessionStartedAt > 0L && + (sessionStartedAt + SESSION_MAX_DURATION) <= now + + private fun rotateLocked(now: Long): UUID { + sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = now + sessionActivityTimestamp = now + return sessionId + } + + private fun clearLocked() { + sessionId = sessionIdNone + sessionStartedAt = 0L + sessionActivityTimestamp = 0L + } + public fun getActiveSessionId(): UUID? { + var sessionChanged = false var tempSessionId: UUID? synchronized(sessionLock) { - tempSessionId = if (sessionId != sessionIdNone) sessionId else null + if (sessionId == sessionIdNone || isReactNative) { + tempSessionId = if (sessionId != sessionIdNone) sessionId else null + } else { + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + // Check inactivity first, then max-duration (mirror iOS order). + if (isIdle(now) || isMaxExpired(now)) { + sessionChanged = true + tempSessionId = + if (isAppInBackground) { + clearLocked() + null + } else { + rotateLocked(now) + } + } else { + tempSessionId = sessionId + } + } + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } return tempSessionId } + /** + * Marks user activity on the current session. Mirrors iOS touchSession(): + * if the session has gone idle past SESSION_INACTIVITY_DURATION, rotates it; + * otherwise just refreshes the activity timestamp. + * + * Called from lifecycle transitions, replay touch interception, and event capture. + * No-op when backgrounded so background events don't keep a dead session alive. + */ + public fun touchSession() { + var sessionChanged = false + synchronized(sessionLock) { + if (isReactNative || isAppInBackground || sessionId == sessionIdNone) return@synchronized + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + if (isIdle(now)) { + rotateLocked(now) + sessionChanged = true + } else { + sessionActivityTimestamp = now + } + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() + } + } + public fun setSessionId(sessionId: UUID) { - // RN can only set its own session id directly synchronized(sessionLock) { + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() this.sessionId = sessionId + // Stamp the start so an externally-set session participates in the 24h + // expiry check; without it sessionStartedAt stays 0 and never expires. + sessionStartedAt = now + sessionActivityTimestamp = now } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 79b6052e..a4a84113 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -833,6 +833,112 @@ internal class PostHogTest { sut.close() } + @Test + fun `capture preserves caller-provided session_id over the session manager`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + sut.startSession() + val managerSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(managerSessionId) + + val callerSessionId = TimeBasedEpochGenerator.generate().toString() + assertNotEquals(managerSessionId.toString(), callerSessionId) + + sut.capture( + EVENT, + DISTINCT_ID, + properties = mapOf("\$session_id" to callerSessionId), + ) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + val theEvent = batch.batch.first() + + assertEquals(callerSessionId, theEvent.properties!!["\$session_id"]) + + sut.close() + } + + @Test + fun `capture rotates idle session via touchSession before reading session id`() { + val http = mockHttp() + val url = http.url("/") + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + // Set up a session whose activity timestamp is 31 minutes in the past, so + // the touchSession at the start of capture() will trip inactivity and rotate. + val realNow = System.currentTimeMillis() + val fakeDate = TestDateProvider(realNow - (1000L * 60 * 31)) + PostHogSessionManager.setDateProvider(fakeDate) + PostHogSessionManager.setSessionId(java.util.UUID.randomUUID()) + val originalSessionId = PostHogSessionManager.getActiveSessionId() + fakeDate.nowMs = realNow + + sut.capture(EVENT, DISTINCT_ID) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + val theEvent = batch.batch.first() + + val eventSessionId = theEvent.properties!!["\$session_id"] as String + assertNotEquals(originalSessionId.toString(), eventSessionId) + + PostHogSessionManager.setDateProvider(com.posthog.internal.PostHogDeviceDateProvider()) + sut.close() + } + + @Test + fun `getter rotation fires session replay handler onSessionIdChanged`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + val sut = getSut(url.toString(), preloadFeatureFlags = false, integration = integration) + + // Force the manager into an expired state: stamp sessionStartedAt with an old + // timestamp via setSessionId, then bump the clock back to "now" so the getter's + // expiry check trips. + val twentyFiveHoursMs = 25L * 60 * 60 * 1000 + val realNow = System.currentTimeMillis() + val fakeDate = TestDateProvider(realNow - twentyFiveHoursMs) + PostHogSessionManager.setDateProvider(fakeDate) + PostHogSessionManager.setSessionId(java.util.UUID.randomUUID()) + fakeDate.nowMs = realNow + + // setSessionId may have triggered onSessionIdChanged via other paths during setup; + // reset before the assertion so we measure the rotation specifically. + integration.onSessionIdChangedCalled = false + + sut.getSessionId() // triggers getter rotation since we're past 24h + + assertTrue(integration.onSessionIdChangedCalled) + + PostHogSessionManager.setDateProvider(com.posthog.internal.PostHogDeviceDateProvider()) + sut.close() + } + + private class TestDateProvider(var nowMs: Long) : com.posthog.internal.PostHogDateProvider { + override fun currentDate(): java.util.Date = java.util.Date(nowMs) + + override fun addSecondsToCurrentDate(seconds: Int): java.util.Date { + val cal = java.util.Calendar.getInstance() + cal.timeInMillis = nowMs + cal.add(java.util.Calendar.SECOND, seconds) + return cal.time + } + + override fun currentTimeMillis(): Long = nowMs + + override fun nanoTime(): Long = System.nanoTime() + } + @Test fun `capture uses generated distinctId if not given`() { val http = mockHttp() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index fc931763..da3f20d8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -1,7 +1,10 @@ package com.posthog.internal +import java.util.Calendar +import java.util.Date import java.util.UUID import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -11,6 +14,15 @@ import kotlin.test.assertNull import kotlin.test.assertTrue internal class PostHogSessionManagerTest { + @BeforeTest + internal fun setUp() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.setAppInBackground(false) + PostHogSessionManager.setOnSessionIdChangedListener(null) + PostHogSessionManager.setDateProvider(PostHogDeviceDateProvider()) + PostHogSessionManager.endSession() + } + @Test internal fun `when React Native, startSession does not create new session`() { PostHogSessionManager.isReactNative = true @@ -66,9 +78,310 @@ internal class PostHogSessionManagerTest { assertNotEquals(firstSessionId, secondSessionId) } + @Test + internal fun `startSession sets sessionStartedAt`() { + PostHogSessionManager.startSession() + + val startedAt = PostHogSessionManager.getSessionStartedAt() + assertTrue(startedAt > 0L) + } + + @Test + internal fun `endSession resets sessionStartedAt to zero`() { + PostHogSessionManager.startSession() + assertTrue(PostHogSessionManager.getSessionStartedAt() > 0L) + + PostHogSessionManager.endSession() + assertEquals(0L, PostHogSessionManager.getSessionStartedAt()) + } + + @Test + internal fun `getSessionStartedAt returns zero when no session is active`() { + assertEquals(0L, PostHogSessionManager.getSessionStartedAt()) + } + + @Test + internal fun `isSessionExceedingMaxDuration returns true after 24 hours`() { + PostHogSessionManager.startSession() + val startedAt = PostHogSessionManager.getSessionStartedAt() + + val twentyFourHoursAndOneMinute = startedAt + (1000L * 60 * 60 * 24) + (1000L * 60) + assertTrue(PostHogSessionManager.isSessionExceedingMaxDuration(twentyFourHoursAndOneMinute)) + } + + @Test + internal fun `isSessionExceedingMaxDuration returns false before 24 hours`() { + PostHogSessionManager.startSession() + val startedAt = PostHogSessionManager.getSessionStartedAt() + + val twentyThreeHours = startedAt + (1000L * 60 * 60 * 23) + assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(twentyThreeHours)) + } + + @Test + internal fun `isSessionExceedingMaxDuration returns false when no session is active`() { + assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(System.currentTimeMillis())) + } + + @Test + internal fun `touchSession refreshes activity timestamp without rotating`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val originalSessionId = PostHogSessionManager.getActiveSessionId() + + // Advance under the inactivity threshold (29 min) + fakeDate.nowMs = baseTime + (1000L * 60 * 29) + PostHogSessionManager.touchSession() + + // Advance past 30 min from the initial start, but only 1 min since touch + fakeDate.nowMs = baseTime + (1000L * 60 * 31) + assertEquals(originalSessionId, PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `touchSession rotates session after 30 min of inactivity`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val originalSessionId = PostHogSessionManager.getActiveSessionId() + + // Advance past inactivity threshold + fakeDate.nowMs = baseTime + (1000L * 60 * 31) + PostHogSessionManager.touchSession() + + val rotatedSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(rotatedSessionId) + assertNotEquals(originalSessionId, rotatedSessionId) + } + + @Test + internal fun `touchSession is no-op when app is backgrounded`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + + PostHogSessionManager.setAppInBackground(true) + fakeDate.nowMs = baseTime + (1000L * 60 * 31) + + // touchSession in bg must NOT refresh the activity timestamp; otherwise the + // subsequent getter wouldn't see the inactivity and clear the session. + PostHogSessionManager.touchSession() + + assertNull(PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `touchSession is no-op when no session is active`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + // No startSession called + PostHogSessionManager.touchSession() + + assertNull(PostHogSessionManager.getActiveSessionId()) + assertEquals(0L, PostHogSessionManager.getSessionStartedAt()) + } + + @Test + internal fun `touchSession is no-op for React Native`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + val rnSessionId = UUID.randomUUID() + PostHogSessionManager.isReactNative = true + PostHogSessionManager.setSessionId(rnSessionId) + + fakeDate.nowMs = baseTime + (1000L * 60 * 60) + PostHogSessionManager.touchSession() + + assertEquals(rnSessionId, PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `getActiveSessionId rotates foregrounded session after inactivity`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val originalSessionId = PostHogSessionManager.getActiveSessionId() + + // Skip past inactivity threshold without calling touchSession + fakeDate.nowMs = baseTime + (1000L * 60 * 31) + + val rotatedSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(rotatedSessionId) + assertNotEquals(originalSessionId, rotatedSessionId) + } + + @Test + internal fun `getActiveSessionId clears backgrounded session after inactivity`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + assertNotNull(PostHogSessionManager.getActiveSessionId()) + + PostHogSessionManager.setAppInBackground(true) + fakeDate.nowMs = baseTime + (1000L * 60 * 31) + + assertNull(PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `setSessionId stamps sessionStartedAt`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.setSessionId(UUID.randomUUID()) + + assertEquals(baseTime, PostHogSessionManager.getSessionStartedAt()) + } + + @Test + internal fun `getActiveSessionId rotates foregrounded session after 24 hours`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + // Advance past 24h; app is foregrounded (default) + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + + val rotatedSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(rotatedSessionId) + assertNotEquals(firstSessionId, rotatedSessionId) + assertTrue(PostHogSessionManager.getSessionStartedAt() > baseTime) + } + + @Test + internal fun `getActiveSessionId clears backgrounded session after 24 hours`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + assertNotNull(PostHogSessionManager.getActiveSessionId()) + + PostHogSessionManager.setAppInBackground(true) + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + + assertNull(PostHogSessionManager.getActiveSessionId()) + assertEquals(0L, PostHogSessionManager.getSessionStartedAt()) + } + + @Test + internal fun `getActiveSessionId does not rotate when React Native`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + val rnSessionId = UUID.randomUUID() + PostHogSessionManager.isReactNative = true + PostHogSessionManager.setSessionId(rnSessionId) + + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 48) + + assertEquals(rnSessionId, PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `getActiveSessionId does not rotate when active and under 24h`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + + // Touch every 25 minutes for 23 hours to keep the session under both + // the 30-min inactivity and 24-hour max-duration thresholds. + var elapsed = 0L + val twentyFiveMin = 1000L * 60 * 25 + val twentyThreeHours = 1000L * 60 * 60 * 23 + while (elapsed + twentyFiveMin <= twentyThreeHours) { + elapsed += twentyFiveMin + fakeDate.nowMs = baseTime + elapsed + PostHogSessionManager.touchSession() + } + + assertEquals(firstSessionId, PostHogSessionManager.getActiveSessionId()) + } + + @Test + internal fun `getActiveSessionId fires listener on rotation`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.startSession() + PostHogSessionManager.getActiveSessionId() // no rotation yet + assertEquals(0, callCount) + + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + PostHogSessionManager.getActiveSessionId() // rotates + assertEquals(1, callCount) + + // Subsequent reads without further expiry don't re-fire + PostHogSessionManager.getActiveSessionId() + assertEquals(1, callCount) + } + + @Test + internal fun `getActiveSessionId fires listener on clear`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.startSession() + PostHogSessionManager.setAppInBackground(true) + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + + assertNull(PostHogSessionManager.getActiveSessionId()) + assertEquals(1, callCount) + } + @AfterTest internal fun cleanup() { PostHogSessionManager.isReactNative = false + PostHogSessionManager.setAppInBackground(false) + PostHogSessionManager.setOnSessionIdChangedListener(null) + PostHogSessionManager.setDateProvider(PostHogDeviceDateProvider()) PostHogSessionManager.endSession() } + + private class FakeDateProvider(var nowMs: Long) : PostHogDateProvider { + override fun currentDate(): Date = Date(nowMs) + + override fun addSecondsToCurrentDate(seconds: Int): Date { + val cal = Calendar.getInstance() + cal.timeInMillis = nowMs + cal.add(Calendar.SECOND, seconds) + return cal.time + } + + override fun currentTimeMillis(): Long = nowMs + + override fun nanoTime(): Long = System.nanoTime() + } }