diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md new file mode 100644 index 000000000..67e89c502 --- /dev/null +++ b/.changeset/gentle-clouds-drift.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Enforce 24-hour maximum session duration and 30-minute inactivity rotation with automatic session rotation, mirroring iOS. Note: `PostHogSessionManager.isAppInBackground` now defaults to `true` until the first lifecycle `onStart` flips it; downstream wrappers (Flutter, RN) that exercise the manager directly in tests may need to call `setAppInBackground(false)` to simulate a foregrounded process. 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 2505fb131..2b511f038 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -15,6 +15,7 @@ import com.posthog.android.internal.PostHogAppInstallIntegration import com.posthog.android.internal.PostHogLifecycleObserverIntegration import com.posthog.android.internal.PostHogMetaPropertiesApplier import com.posthog.android.internal.PostHogSharedPreferences +import com.posthog.android.internal.PostHogTouchActivityIntegration import com.posthog.android.internal.appContext import com.posthog.android.internal.getPackageInfo import com.posthog.android.internal.versionCodeCompat @@ -107,8 +108,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) @@ -120,6 +123,9 @@ public class PostHogAndroid private constructor() { } PostHogSessionManager.isReactNative = config.sdkName == "posthog-react-native" + // Mark the process as backgrounded until the first onStart fires; an expired + // session before any UI exists is cleared rather than silently rotated. + PostHogSessionManager.setAppInBackground(true) val releaseIdentifierFallback = "$packageName@$versionName+$buildNumber" val metaPropertiesApplier = PostHogMetaPropertiesApplier() @@ -130,6 +136,7 @@ public class PostHogAndroid private constructor() { val mainHandler = MainHandler() config.addIntegration(PostHogReplayIntegration(context, config, mainHandler)) + config.addIntegration(PostHogTouchActivityIntegration(config)) config.addIntegration(PostHogLogCatIntegration(config)) if (context is Application) { if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay) { 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 7fd4d5f76..5a8045c5a 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,9 +8,9 @@ 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.AtomicLong /** * Captures app opened and backgrounded events @@ -27,8 +27,9 @@ internal class PostHogLifecycleObserverIntegration( private val timerLock = Any() private var timer = Timer(true) private var timerTask: TimerTask? = null - private val lastUpdatedSession = AtomicLong(0L) - private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes + + // Bg timeout for forcing endSession when no events fire to drive the manager's getter check. + private val bgEndSessionDelayMs = (1000 * 60 * 30).toLong() // 30 minutes private var postHog: PostHogInterface? = null @@ -44,7 +45,13 @@ internal class PostHogLifecycleObserverIntegration( } override fun onStart(owner: LifecycleOwner) { - startSession() + cancelTask() + PostHogSessionManager.setAppInBackground(false) + // touchSession rotates an idle session; startSession creates a fresh one if the + // session was cleared during bg. Both fire the manager's session-id-changed listener, + // which drives the sampling-aware replay restart in the replay integration. + PostHogSessionManager.touchSession() + postHog?.startSession() if (config.captureApplicationLifecycleEvents) { val props = mutableMapOf() @@ -63,20 +70,6 @@ internal class PostHogLifecycleObserverIntegration( } } - private fun startSession() { - cancelTask() - - val currentTimeMillis = config.dateProvider.currentTimeMillis() - val lastUpdatedSession = lastUpdatedSession.get() - - if (lastUpdatedSession == 0L || - (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis - ) { - postHog?.startSession() - } - this.lastUpdatedSession.set(currentTimeMillis) - } - private fun cancelTask() { synchronized(timerLock) { timerTask?.cancel() @@ -85,6 +78,9 @@ internal class PostHogLifecycleObserverIntegration( } private fun scheduleEndSession() { + // Backgrounded apps may fire no events, so the getter's idle check never runs; + // this timer guarantees the session ends after 30 min of bg per the + // PostHogInterface.endSession docstring. synchronized(timerLock) { cancelTask() timerTask = @@ -93,19 +89,35 @@ internal class PostHogLifecycleObserverIntegration( postHog?.endSession() } } - timer.schedule(timerTask, sessionMaxInterval) + timer.schedule(timerTask, bgEndSessionDelayMs) } } override fun onStop(owner: LifecycleOwner) { + val currentTimeMillis = config.dateProvider.currentTimeMillis() + // Capture expiry before flipping the bg flag: once bg=true, getActiveSessionId + // would clear an expired session and zero sessionStartedAt, hiding it from the + // wasExpired branch below. + val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis) + + // Touch while still foregrounded so the activity timestamp moves forward; doing + // this after setAppInBackground(true) would no-op. + PostHogSessionManager.touchSession() + PostHogSessionManager.setAppInBackground(true) if (config.captureApplicationLifecycleEvents) { postHog?.capture("Application Backgrounded") } postHog?.flush() - val currentTimeMillis = config.dateProvider.currentTimeMillis() - lastUpdatedSession.set(currentTimeMillis) - scheduleEndSession() + if (wasExpired) { + cancelTask() + // Force the rotation now and stop replay synchronously — process may suspend + // before the listener's main-thread post can run. + postHog?.endSession() + postHog?.stopSessionReplay() + } else { + scheduleEndSession() + } } private fun add() { diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt new file mode 100644 index 000000000..459b9985b --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt @@ -0,0 +1,91 @@ +package com.posthog.android.internal + +import android.os.Build +import com.posthog.PostHogIntegration +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.PostHogSessionManager +import curtains.Curtains +import curtains.OnRootViewsChangedListener +import curtains.TouchEventInterceptor +import curtains.phoneWindow +import curtains.touchEventInterceptors + +/** + * Marks user touches as session activity by calling [PostHogSessionManager.touchSession] + * on every dispatched MotionEvent. + * + * Decoupled from session replay so apps with replay disabled or sampled out still get + * touch-driven inactivity rotation; otherwise session-id rotation behaviour would depend + * on whether the user happened to be sampled, which session metrics rely on being stable. + */ +internal class PostHogTouchActivityIntegration( + private val config: PostHogAndroidConfig, +) : PostHogIntegration { + private companion object { + @Volatile + private var integrationInstalled = false + } + + private val touchInterceptor = + TouchEventInterceptor { motionEvent, dispatch -> + try { + PostHogSessionManager.touchSession() + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration touchSession failed: $e.") + } + dispatch(motionEvent) + } + + private val onRootViewsChangedListener = + OnRootViewsChangedListener { view, added -> + try { + val window = view.phoneWindow ?: return@OnRootViewsChangedListener + if (added) { + if (touchInterceptor !in window.touchEventInterceptors) { + window.touchEventInterceptors += touchInterceptor + } + } else { + window.touchEventInterceptors -= touchInterceptor + } + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration root view changed failed: $e.") + } + } + + override fun install(postHog: PostHogInterface) { + if (integrationInstalled || !isSupported()) { + return + } + integrationInstalled = true + try { + Curtains.rootViews.forEach { view -> + view.phoneWindow?.let { window -> + if (touchInterceptor !in window.touchEventInterceptors) { + window.touchEventInterceptors += touchInterceptor + } + } + } + Curtains.onRootViewsChangedListeners += onRootViewsChangedListener + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration install failed: $e.") + } + } + + override fun uninstall() { + try { + Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener + Curtains.rootViews.forEach { view -> + view.phoneWindow?.let { window -> + window.touchEventInterceptors -= touchInterceptor + } + } + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") + } finally { + integrationInstalled = false + } + } + + private fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O +} 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 54a8170be..c55390063 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,7 +276,6 @@ public class PostHogReplayIntegration( private val onTouchEventListener = TouchEventInterceptor { motionEvent, dispatch -> val timestamp = config.dateProvider.currentTimeMillis() - try { val state = dispatch(motionEvent) try { @@ -1606,6 +1605,15 @@ public class PostHogReplayIntegration( } isSessionReplayActive = true + + if (!resumeCurrent) { + // Without this, on a static UI the first user-driven onDraw can be tens of seconds + // away — and incremental events (type:3) would ship under the new session before + // the meta + full-snapshot keyframes (type:4 + type:2) needed to render them. + mainHandler.handler.post { + decorViews.keys.forEach { it.postInvalidate() } + } + } } private fun clearSnapshotStates() { @@ -1662,22 +1670,60 @@ 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-initialises recording so the + * new session gets fresh meta + full wireframe events. */ override fun onSessionIdChanged() { - val postHog = this.postHog ?: return + if (this.postHog == null) return - val currentSessionId = postHog.getSessionId()?.toString() + // Read-only: getActiveSessionId() can rotate the session and would re-fire this listener. + val currentSessionId = PostHogSessionManager.peekSessionId()?.toString() - val triggers = config.remoteConfigHolder?.getEventTriggers() + val remoteConfig = config.remoteConfigHolder + val triggers = remoteConfig?.getEventTriggers() val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId } - // If triggers are configured and this session hasn't been activated, stop the integration if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) { if (isSessionReplayActive) { config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.") stop() } + return + } + + // The listener can fire from any thread that calls capture(); replay state writes + // (snapshot WeakHashMap, isSessionReplayActive) must happen on main. + if (currentSessionId == null) { + if (isSessionReplayActive) { + config.logger.log("[Session Replay] Session cleared. Stopping recording.") + mainHandler.handler.post { stop() } + } + return + } + + // Run regardless of isSessionReplayActive: the prior session may have been sampled out + // and the new one may now pass. Sampling is re-evaluated for the (already-current) + // session without rotating the id (the silent rotation that fired this already + // rotated; going through PostHog.startSessionReplay(false) would double-rotate). + config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") + mainHandler.handler.post { + // config.sessionReplay is the customer-facing master switch: a config-level disable + // must not be overridden by remote flag + sampling. Manual PostHog.startSessionReplay + // calls and trigger-matched starts go through different code paths and are unaffected. + if (!config.sessionReplay) { + if (isSessionReplayActive) stop() + return@post + } + if (remoteConfig?.isSessionReplayFlagActive() != true) { + if (isSessionReplayActive) stop() + return@post + } + if (remoteConfig.makeSamplingDecision(currentSessionId).not()) { + if (isSessionReplayActive) stop() + return@post + } + if (isSessionReplayActive) 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 1960d8086..948b7988a 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 b5b9779c7..f1db8f844 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,20 @@ 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 +import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) internal class PostHogLifecycleObserverIntegrationTest { @@ -30,6 +39,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 +156,236 @@ 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) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + sut.onStart(ProcessLifecycleOwner.get()) + + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val oneMinuteMs = 1000L * 60 + + // Advance past the 24h cap and cycle through bg→fg. onStop's wasExpired branch + // ends the session; the next onStart calls startSession on the cleared manager, + // which mints a fresh id. + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + assertNotEquals(firstSessionId, secondSessionId) + + sut.uninstall() + } + + @Test + fun `onStop ends session and stops replay synchronously when 24h expired`() { + // Replay restart on the next onStart is now driven by the manager's listener + // (covered in PostHogReplayIntegrationTest); here we verify only the synchronous + // teardown that has to happen before the process suspends. + 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()) + + val twentyFourHoursMs = 1000L * 60 * 60 * 24 + val oneMinuteMs = 1000L * 60 + fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs + sut.onStop(ProcessLifecycleOwner.get()) + + assertEquals(1, fake.stopSessionReplayCalls) + assertEquals(false, fake.sessionReplayActive) + // Session was ended; the next onStart will create a fresh one. + assertNull(PostHogSessionManager.getActiveSessionId()) + + sut.uninstall() + } + + @Test + fun `onStart creates a fresh session after a 24h-expired onStop`() { + 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) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + 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()) + + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + assertNotEquals(firstSessionId, secondSessionId) + + 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) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + sut.onStart(ProcessLifecycleOwner.get()) + + val fiveMinutesMs = 1000L * 60 * 5 + fakeDateProvider.currentTimeMs = baseTime + fiveMinutesMs + + sut.onStop(ProcessLifecycleOwner.get()) + sut.onStart(ProcessLifecycleOwner.get()) + + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertEquals(firstSessionId, secondSessionId) + + sut.uninstall() + } + + @Test + fun `onStop touches the session before flipping the bg flag`() { + // Without the touch in onStop, the activity timestamp would still point at session + // start. After bg+30min the getter would clear the session even though the user just + // backgrounded the app a few minutes ago. + 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) + + PostHogSessionManager.startSession() + val sessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(sessionId) + + sut.onStart(ProcessLifecycleOwner.get()) + + // Sit foregrounded for 25 min with no activity (under the 30-min idle threshold). + val twentyFiveMinutesMs = 1000L * 60 * 25 + fakeDateProvider.currentTimeMs = baseTime + twentyFiveMinutesMs + + // Backgrounding — onStop must touch before flipping the bg flag. + sut.onStop(ProcessLifecycleOwner.get()) + + // 35 min total: 10 min since the onStop touch, well under the 30-min idle threshold. + // The getter under bg=true should preserve the session (clears only if idle is reached). + fakeDateProvider.currentTimeMs = baseTime + (1000L * 60 * 35) + assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) + + 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 owns its session id; the SDK must not rotate it even past the 24h cap. + val sessionId = java.util.UUID.randomUUID() + PostHogSessionManager.setSessionId(sessionId) + + 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(sessionId, PostHogSessionManager.getActiveSessionId()) + + sut.uninstall() + } + + 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-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt new file mode 100644 index 000000000..813eaa36b --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt @@ -0,0 +1,43 @@ +package com.posthog.android.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.createPostHogFake +import org.junit.runner.RunWith +import kotlin.test.Test + +@RunWith(AndroidJUnit4::class) +internal class PostHogTouchActivityIntegrationTest { + private fun getSut(): PostHogTouchActivityIntegration { + val config = PostHogAndroidConfig(API_KEY) + return PostHogTouchActivityIntegration(config) + } + + @Test + fun `install and uninstall complete without throwing on a clean process`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.uninstall() + } + + @Test + fun `double install is idempotent`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.install(fake) + sut.uninstall() + } + + @Test + fun `uninstall after install can be re-installed`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.uninstall() + sut.install(fake) + sut.uninstall() + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt new file mode 100644 index 000000000..fd532daca --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -0,0 +1,211 @@ +package com.posthog.android.replay + +import android.content.Context +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.createPostHogFake +import com.posthog.android.internal.MainHandler +import com.posthog.internal.PostHogRemoteConfig +import com.posthog.internal.PostHogSessionManager +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) // PostHogReplayIntegration.isSupported() requires API >= O. +internal class PostHogReplayIntegrationTest { + private val context = mock() + + @BeforeTest + fun `set up`() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.setAppInBackground(false) + PostHogSessionManager.endSession() + } + + @AfterTest + fun `tear down`() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.endSession() + PostHogSessionManager.setAppInBackground(true) + } + + private fun configWithSampling( + flagActive: Boolean, + samplingPasses: Boolean, + sessionReplay: Boolean = true, + ): PostHogAndroidConfig { + val remoteConfig = + mock { + on { isSessionReplayFlagActive() } doReturn flagActive + on { makeSamplingDecision(any()) } doReturn samplingPasses + on { getEventTriggers() } doReturn emptySet() + } + return PostHogAndroidConfig(API_KEY).apply { + remoteConfigHolder = remoteConfig + this.sessionReplay = sessionReplay + } + } + + private fun getSut(config: PostHogAndroidConfig = PostHogAndroidConfig(API_KEY)): PostHogReplayIntegration { + return PostHogReplayIntegration(context, config, MainHandler()) + } + + @Test + fun `onSessionIdChanged starts replay when previously inactive and sampling passes`() { + // The prior session may have been sampled out; rotation must re-evaluate sampling and + // start replay even though isSessionReplayActive was false. + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) + val fake = createPostHogFake() + fake.sessionReplayActive = false + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops then starts replay when active and sampling passes`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + // Pre-activate replay so we can verify it's stopped+restarted, not just left running. + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops replay when sampling fails`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = false)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops replay when session is cleared`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + // Clear the session, then fire onSessionIdChanged — peekSessionId returns null. + PostHogSessionManager.endSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged does not start replay when flag is disabled`() { + val sut = getSut(configWithSampling(flagActive = false, samplingPasses = true)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged does not auto-start replay when config sessionReplay is false`() { + // config.sessionReplay is the master switch — even if remote flag and sampling both + // pass, we must not auto-start replay if the customer disabled it at config level. + val sut = + getSut( + configWithSampling( + flagActive = true, + samplingPasses = true, + sessionReplay = false, + ), + ) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops active replay on rotation when config sessionReplay is false`() { + // Defensive: if replay was somehow started (e.g. trigger-matched, or pre-config-flip), + // a rotation under config.sessionReplay = false should stop it rather than restart. + val sut = + getSut( + configWithSampling( + flagActive = true, + samplingPasses = true, + sessionReplay = false, + ), + ) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } +} 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 eacc579ad..a061438f5 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 f6422522f..f98c62d43 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -898,9 +898,14 @@ public final class com/posthog/internal/PostHogSessionManager { public final fun getActiveSessionId ()Ljava/util/UUID; public final fun isReactNative ()Z public final fun isSessionActive ()Z + public final fun isSessionExceedingMaxDuration (J)Z + public final fun peekSessionId ()Ljava/util/UUID; + public final fun setAppInBackground (Z)V + public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)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 82c6a68ce..1d334379e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -190,6 +190,14 @@ public class PostHog private constructor( queue.start() + PostHogSessionManager.setOnSessionIdChangedListener { + try { + sessionReplayHandler?.onSessionIdChanged() + } catch (e: Throwable) { + config.logger.log("onSessionIdChanged listener failed: $e.") + } + } + startSession() config.integrations.forEach { @@ -308,6 +316,8 @@ public class PostHog private constructor( featureFlagsCalled.clear() + PostHogSessionManager.setOnSessionIdChangedListener(null) + endSession() } catch (e: Throwable) { config?.logger?.log("Close failed: $e.") @@ -433,8 +443,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) { @@ -1333,8 +1349,6 @@ public class PostHog private constructor( } PostHogSessionManager.startSession() - // Notify session replay handler about session change for event triggers - sessionReplayHandler?.onSessionIdChanged() } override fun endSession() { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 4f0ee1ee7..91f6e3fb6 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -16,45 +16,192 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone + @Volatile + 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 + // Default false so non-Android JVM consumers (no lifecycle to flip this) rotate + // expired sessions instead of clearing them. PostHogAndroid explicitly sets this + // to true at SDK init to preserve the "no UI yet at startup" semantic; the lifecycle + // observer flips it back to false on the first onStart. + @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. + */ + internal fun setOnSessionIdChangedListener(listener: (() -> Unit)?) { + onSessionIdChangedListener = listener + } + public fun startSession() { - if (isReactNative) { - // RN manages its own session - return + var sessionChanged = false + synchronized(sessionLock) { + if (isReactNative || sessionId != sessionIdNone) return@synchronized + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = now + sessionActivityTimestamp = now + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } + } + public fun endSession() { + var sessionChanged = false synchronized(sessionLock) { - if (sessionId == sessionIdNone) { - sessionId = TimeBasedEpochGenerator.generate() - } + if (isReactNative || sessionId == sessionIdNone) return@synchronized + clearLocked() + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } } - 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. Test-only. + */ + internal fun getSessionStartedAt(): Long { + synchronized(sessionLock) { + return sessionStartedAt } + } + /** + * Returns true if the current session has been active for longer than 24 hours. + * Always false for React Native — JS owns the session lifecycle and the native side + * must not drive rotation decisions on top of it. + */ + public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { synchronized(sessionLock) { - sessionId = sessionIdNone + if (isReactNative) return false + 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 + + // Caller must hold sessionLock. + 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() + 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: rotates if idle past + * [SESSION_INACTIVITY_DURATION], otherwise refreshes the activity timestamp. + * 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 + var sessionChanged = false synchronized(sessionLock) { + // Re-asserting the same id (e.g. RN syncing the active id on every event) must not + // reset the 24h max-duration or 30-min inactivity clocks — sessions would never expire. + if (this.sessionId == sessionId) return@synchronized + val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() this.sessionId = sessionId + sessionStartedAt = now + sessionActivityTimestamp = now + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } } @@ -65,4 +212,15 @@ public object PostHogSessionManager { } return active } + + /** + * Read-only sibling of [getActiveSessionId]: skips the expiry checks so callers reacting + * to a session-id change can read the new id without risking a re-entrant rotation that + * would re-fire the listener. + */ + public fun peekSessionId(): UUID? { + synchronized(sessionLock) { + return if (sessionId == sessionIdNone) null else sessionId + } + } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 79b6052e8..1370283f3 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -833,6 +833,82 @@ 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 `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. Foreground so the getter rotates instead of clearing. + PostHogSessionManager.setAppInBackground(false) + 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 fc9317635..3a1c75256 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,385 @@ 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 `isSessionExceedingMaxDuration returns false for React Native even past 24h`() { + // RN owns the session lifecycle from JS; the native side must not drive rotation + // decisions on top of it. + PostHogSessionManager.isReactNative = true + PostHogSessionManager.setSessionId(UUID.randomUUID()) + val startedAt = PostHogSessionManager.getSessionStartedAt() + + val twentyFiveHours = startedAt + (1000L * 60 * 60 * 25) + assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(twentyFiveHours)) + } + + @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() + + // Touch at 29m resets the inactivity origin; 31m total is only 2m since touch. + fakeDate.nowMs = baseTime + (1000L * 60 * 29) + PostHogSessionManager.touchSession() + + 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() + + 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) + + // If touchSession refreshed the activity timestamp here, the getter wouldn't see + // the inactivity and would not clear. + 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) + + 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() + + // No touchSession call — exercising the getter's own inactivity check. + 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 `setSessionId is a no-op when the id is unchanged`() { + // Re-asserting the same id (e.g. RN syncing the current id on every event) must not + // reset the 24h max-duration or 30-min inactivity clocks. + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + val id = UUID.randomUUID() + PostHogSessionManager.setSessionId(id) + val firstStartedAt = PostHogSessionManager.getSessionStartedAt() + assertEquals(baseTime, firstStartedAt) + + fakeDate.nowMs = baseTime + (1000L * 60 * 10) + PostHogSessionManager.setSessionId(id) + assertEquals(firstStartedAt, PostHogSessionManager.getSessionStartedAt()) + + PostHogSessionManager.setSessionId(UUID.randomUUID()) + assertEquals(fakeDate.nowMs, 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) + + 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) + + // Wire the listener AFTER startSession so the explicit start-fire isn't counted. + PostHogSessionManager.startSession() + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.getActiveSessionId() + assertEquals(0, callCount) + + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + PostHogSessionManager.getActiveSessionId() + assertEquals(1, callCount) + + 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) + + PostHogSessionManager.startSession() + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.setAppInBackground(true) + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 + + assertNull(PostHogSessionManager.getActiveSessionId()) + assertEquals(1, callCount) + } + + @Test + internal fun `startSession fires listener when a new session is created`() { + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.startSession() + assertEquals(1, callCount) + + // Idempotent re-assert on an already-active session must not refire. + PostHogSessionManager.startSession() + assertEquals(1, callCount) + } + + @Test + internal fun `endSession fires listener once when an active session is cleared`() { + PostHogSessionManager.startSession() + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.endSession() + assertEquals(1, callCount) + + PostHogSessionManager.endSession() + assertEquals(1, callCount) + } + + @Test + internal fun `setSessionId fires listener only when the id actually changes`() { + val firstId = UUID.randomUUID() + val sameId = firstId + val secondId = UUID.randomUUID() + + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.setSessionId(firstId) + assertEquals(1, callCount) + + // Re-asserting the same id (e.g. RN syncing on every event) must not refire. + PostHogSessionManager.setSessionId(sameId) + assertEquals(1, callCount) + + PostHogSessionManager.setSessionId(secondId) + assertEquals(2, 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() + } }