From c653034c8ec0d1afaae5c07378cb37a338212188 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:42:13 +0100 Subject: [PATCH 01/15] fix: use PostHogDateProvider in PostHogSessionManager instead of System.currentTimeMillis() --- .changeset/gentle-clouds-drift.md | 6 + .../PostHogLifecycleObserverIntegration.kt | 11 ++ ...PostHogLifecycleObserverIntegrationTest.kt | 125 ++++++++++++++++++ posthog/api/posthog.api | 4 + .../posthog/internal/PostHogSessionManager.kt | 36 +++++ .../internal/PostHogSessionManagerTest.kt | 62 +++++++++ 6 files changed, 244 insertions(+) create mode 100644 .changeset/gentle-clouds-drift.md diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md new file mode 100644 index 00000000..5783e05a --- /dev/null +++ b/.changeset/gentle-clouds-drift.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Use PostHogDateProvider instead of System.currentTimeMillis() in PostHogSessionManager for testability 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..48dae7c2 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,6 +8,7 @@ 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 @@ -29,6 +30,7 @@ internal class PostHogLifecycleObserverIntegration( private var timerTask: TimerTask? = null private val lastUpdatedSession = AtomicLong(0L) private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes + private val sessionMaxDuration = (1000 * 60 * 60 * 24).toLong() // 24 hours private var postHog: PostHogInterface? = null @@ -73,10 +75,19 @@ internal class PostHogLifecycleObserverIntegration( (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis ) { postHog?.startSession() + } else if (isSessionExceedingMaxDuration(currentTimeMillis)) { + // Session has been active for longer than 24 hours, rotate to a new session + PostHogSessionManager.rotateSession() } this.lastUpdatedSession.set(currentTimeMillis) } + private fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { + val sessionStartedAt = PostHogSessionManager.getSessionStartedAt() + return sessionStartedAt > 0L && + (sessionStartedAt + sessionMaxDuration) <= currentTimeMillis + } + private fun cancelTask() { synchronized(timerLock) { timerTask?.cancel() 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..a64c7290 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,13 @@ internal class PostHogLifecycleObserverIntegrationTest { @BeforeTest fun `set up`() { PostHog.resetSharedInstance() + PostHogSessionManager.endSession() + } + + @AfterTest + fun `tear down`() { + PostHogSessionManager.dateProvider = PostHogDeviceDateProvider() + PostHogSessionManager.endSession() } @Test @@ -139,4 +154,114 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.uninstall() } + + @Test + fun `onStart rotates session when session exceeds 24 hours`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.dateProvider = 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 does not rotate session when session is under 24 hours`() { + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.dateProvider = 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() + } + + /** + * 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/api/posthog.api b/posthog/api/posthog.api index d633c274..81f405ba 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -889,8 +889,12 @@ 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 getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; + public final fun getSessionStartedAt ()J public final fun isReactNative ()Z public final fun isSessionActive ()Z + public final fun rotateSession ()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 diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 4f0ee1ee..0f60ca34 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -16,6 +16,14 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone + public var dateProvider: PostHogDateProvider = PostHogDeviceDateProvider() + + /** + * Timestamp (in milliseconds) when the current session was started. + * Reset to 0 when the session ends. + */ + private var sessionStartedAt: Long = 0L + @Volatile public var isReactNative: Boolean = false @@ -28,6 +36,7 @@ public object PostHogSessionManager { synchronized(sessionLock) { if (sessionId == sessionIdNone) { sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = dateProvider.currentTimeMillis() } } } @@ -40,6 +49,33 @@ public object PostHogSessionManager { synchronized(sessionLock) { sessionId = sessionIdNone + sessionStartedAt = 0L + } + } + + /** + * Atomically ends the current session and starts a new one. + * This is used when the session exceeds the maximum allowed duration (e.g. 24 hours). + */ + public fun rotateSession() { + if (isReactNative) { + // RN manages its own session + return + } + + synchronized(sessionLock) { + sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = dateProvider.currentTimeMillis() + } + } + + /** + * Returns the timestamp (in milliseconds) when the current session was started, + * or 0 if no session is active. + */ + public fun getSessionStartedAt(): Long { + synchronized(sessionLock) { + return sessionStartedAt } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index fc931763..0c14cb68 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -66,9 +66,71 @@ 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 `rotateSession creates a new session with a new id`() { + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) + + PostHogSessionManager.rotateSession() + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + + assertNotEquals(firstSessionId, secondSessionId) + assertTrue(PostHogSessionManager.isSessionActive()) + } + + @Test + internal fun `rotateSession updates sessionStartedAt`() { + PostHogSessionManager.startSession() + val firstStartedAt = PostHogSessionManager.getSessionStartedAt() + assertTrue(firstStartedAt > 0L) + + // Small delay to ensure different timestamp + Thread.sleep(10) + + PostHogSessionManager.rotateSession() + val secondStartedAt = PostHogSessionManager.getSessionStartedAt() + assertTrue(secondStartedAt >= firstStartedAt) + } + + @Test + internal fun `when React Native, rotateSession does not rotate session`() { + PostHogSessionManager.isReactNative = true + val sessionId = UUID.randomUUID() + PostHogSessionManager.setSessionId(sessionId) + + PostHogSessionManager.rotateSession() + + assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) + } + @AfterTest internal fun cleanup() { PostHogSessionManager.isReactNative = false + PostHogSessionManager.dateProvider = PostHogDeviceDateProvider() PostHogSessionManager.endSession() } } From f6f117bc6ef1d537cb5151a627d1bb161b7a02da Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:46:09 +0100 Subject: [PATCH 02/15] fix: skip session rotation for React Native in lifecycle observer --- .../PostHogLifecycleObserverIntegration.kt | 2 +- ...PostHogLifecycleObserverIntegrationTest.kt | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 48dae7c2..beb35599 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 @@ -75,7 +75,7 @@ internal class PostHogLifecycleObserverIntegration( (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis ) { postHog?.startSession() - } else if (isSessionExceedingMaxDuration(currentTimeMillis)) { + } else if (!PostHogSessionManager.isReactNative && isSessionExceedingMaxDuration(currentTimeMillis)) { // Session has been active for longer than 24 hours, rotate to a new session PostHogSessionManager.rotateSession() } 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 a64c7290..3ce052b3 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 @@ -43,6 +43,7 @@ internal class PostHogLifecycleObserverIntegrationTest { @AfterTest fun `tear down`() { + PostHogSessionManager.isReactNative = false PostHogSessionManager.dateProvider = PostHogDeviceDateProvider() PostHogSessionManager.endSession() } @@ -245,6 +246,43 @@ internal class PostHogLifecycleObserverIntegrationTest { 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.dateProvider = 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. */ From 09e64fcc07c72b53df846d13ed713b06a52c76bc Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:47:58 +0100 Subject: [PATCH 03/15] chore: update PR description and changeset for 24h session limit --- .changeset/gentle-clouds-drift.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md index 5783e05a..09608a68 100644 --- a/.changeset/gentle-clouds-drift.md +++ b/.changeset/gentle-clouds-drift.md @@ -1,6 +1,6 @@ --- -'posthog': patch -'posthog-android': patch +'posthog': minor +'posthog-android': minor --- -Use PostHogDateProvider instead of System.currentTimeMillis() in PostHogSessionManager for testability +Enforce 24-hour maximum session duration with automatic session rotation From 7de0557b366b3ababc8fec2cc2104d93043f4677 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:48:12 +0100 Subject: [PATCH 04/15] chore: changeset to patch --- .changeset/gentle-clouds-drift.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md index 09608a68..a6434a8c 100644 --- a/.changeset/gentle-clouds-drift.md +++ b/.changeset/gentle-clouds-drift.md @@ -1,6 +1,6 @@ --- -'posthog': minor -'posthog-android': minor +'posthog': patch +'posthog-android': patch --- Enforce 24-hour maximum session duration with automatic session rotation From 30480c13190e5ac3bca9908c1d28aee6cbbe9ca4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:31:46 +0100 Subject: [PATCH 05/15] fix --- gradle/gradle-daemon-jvm.properties | 3 ++ .../PostHogLifecycleObserverIntegration.kt | 33 ++++++++++++------- .../java/com/posthog/android/PostHogFake.kt | 5 ++- .../java/com/posthog/android/sample/MyApp.kt | 4 +-- posthog/api/posthog.api | 1 + .../posthog/internal/PostHogSessionManager.kt | 12 +++++++ .../internal/PostHogSessionManagerTest.kt | 23 +++++++++++++ 7 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..32ffc8f9 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,3 @@ +#This file is generated by updateDaemonJvm +toolchainVendor=amazon +toolchainVersion=17 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 beb35599..3b66192b 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 @@ -30,7 +30,6 @@ internal class PostHogLifecycleObserverIntegration( private var timerTask: TimerTask? = null private val lastUpdatedSession = AtomicLong(0L) private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes - private val sessionMaxDuration = (1000 * 60 * 60 * 24).toLong() // 24 hours private var postHog: PostHogInterface? = null @@ -75,19 +74,21 @@ internal class PostHogLifecycleObserverIntegration( (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis ) { postHog?.startSession() - } else if (!PostHogSessionManager.isReactNative && isSessionExceedingMaxDuration(currentTimeMillis)) { + } else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { // Session has been active for longer than 24 hours, rotate to a new session - PostHogSessionManager.rotateSession() + 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) } - private fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { - val sessionStartedAt = PostHogSessionManager.getSessionStartedAt() - return sessionStartedAt > 0L && - (sessionStartedAt + sessionMaxDuration) <= currentTimeMillis - } - private fun cancelTask() { synchronized(timerLock) { timerTask?.cancel() @@ -115,8 +116,18 @@ internal class PostHogLifecycleObserverIntegration( 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 (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { + cancelTask() + postHog?.endSession() + postHog?.stopSessionReplay() + // 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/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 1960d808..cf1aafd5 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 @@ -179,13 +180,15 @@ 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 { 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 81f405ba..998d54fa 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -893,6 +893,7 @@ public final class com/posthog/internal/PostHogSessionManager { public final fun getSessionStartedAt ()J public final fun isReactNative ()Z public final fun isSessionActive ()Z + public final fun isSessionExceedingMaxDuration (J)Z public final fun rotateSession ()V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V public final fun setReactNative (Z)V diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 0f60ca34..6fcc2181 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -79,6 +79,18 @@ public object PostHogSessionManager { } } + /** + * Returns true if the current session has been active for longer than 24 hours. + */ + public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { + synchronized(sessionLock) { + return sessionStartedAt > 0L && + (sessionStartedAt + SESSION_MAX_DURATION) <= currentTimeMillis + } + } + + private val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours + public fun getActiveSessionId(): UUID? { var tempSessionId: UUID? synchronized(sessionLock) { diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index 0c14cb68..dba31189 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -127,6 +127,29 @@ internal class PostHogSessionManagerTest { assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) } + @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())) + } + @AfterTest internal fun cleanup() { PostHogSessionManager.isReactNative = false From edf88a3e844996264214aee4ecd5005e91787fef Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:32:09 +0100 Subject: [PATCH 06/15] fix --- gradle/gradle-daemon-jvm.properties | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index 32ffc8f9..00000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,3 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainVendor=amazon -toolchainVersion=17 From 54296e2c9d8ed0f6f08a814e130abab8398bfff0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:41:15 +0100 Subject: [PATCH 07/15] ref --- .../com/posthog/android/PostHogAndroid.kt | 2 + posthog/api/posthog.api | 2 - .../posthog/internal/PostHogSessionManager.kt | 28 +++++-------- .../internal/PostHogSessionManagerTest.kt | 39 ------------------- 4 files changed, 11 insertions(+), 60 deletions(-) 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/api/posthog.api b/posthog/api/posthog.api index 998d54fa..709235f3 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -889,12 +889,10 @@ 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 getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; public final fun getSessionStartedAt ()J public final fun isReactNative ()Z public final fun isSessionActive ()Z public final fun isSessionExceedingMaxDuration (J)Z - public final fun rotateSession ()V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V public final fun setReactNative (Z)V public final fun setSessionId (Ljava/util/UUID;)V diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 6fcc2181..49d1acd3 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,7 +17,11 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone - public var dateProvider: PostHogDateProvider = PostHogDeviceDateProvider() + private var dateProvider: PostHogDateProvider? = null + + public fun setDateProvider(dateProvider: PostHogDateProvider) { + this.dateProvider = dateProvider + } /** * Timestamp (in milliseconds) when the current session was started. @@ -36,7 +41,7 @@ public object PostHogSessionManager { synchronized(sessionLock) { if (sessionId == sessionIdNone) { sessionId = TimeBasedEpochGenerator.generate() - sessionStartedAt = dateProvider.currentTimeMillis() + sessionStartedAt = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() } } } @@ -53,26 +58,11 @@ public object PostHogSessionManager { } } - /** - * Atomically ends the current session and starts a new one. - * This is used when the session exceeds the maximum allowed duration (e.g. 24 hours). - */ - public fun rotateSession() { - if (isReactNative) { - // RN manages its own session - return - } - - synchronized(sessionLock) { - sessionId = TimeBasedEpochGenerator.generate() - sessionStartedAt = dateProvider.currentTimeMillis() - } - } - /** * 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 @@ -89,7 +79,7 @@ public object PostHogSessionManager { } } - private val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours + private const val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours public fun getActiveSessionId(): UUID? { var tempSessionId: UUID? diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index dba31189..07d29b2d 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -88,45 +88,6 @@ internal class PostHogSessionManagerTest { assertEquals(0L, PostHogSessionManager.getSessionStartedAt()) } - @Test - internal fun `rotateSession creates a new session with a new id`() { - PostHogSessionManager.startSession() - val firstSessionId = PostHogSessionManager.getActiveSessionId() - assertNotNull(firstSessionId) - - PostHogSessionManager.rotateSession() - val secondSessionId = PostHogSessionManager.getActiveSessionId() - assertNotNull(secondSessionId) - - assertNotEquals(firstSessionId, secondSessionId) - assertTrue(PostHogSessionManager.isSessionActive()) - } - - @Test - internal fun `rotateSession updates sessionStartedAt`() { - PostHogSessionManager.startSession() - val firstStartedAt = PostHogSessionManager.getSessionStartedAt() - assertTrue(firstStartedAt > 0L) - - // Small delay to ensure different timestamp - Thread.sleep(10) - - PostHogSessionManager.rotateSession() - val secondStartedAt = PostHogSessionManager.getSessionStartedAt() - assertTrue(secondStartedAt >= firstStartedAt) - } - - @Test - internal fun `when React Native, rotateSession does not rotate session`() { - PostHogSessionManager.isReactNative = true - val sessionId = UUID.randomUUID() - PostHogSessionManager.setSessionId(sessionId) - - PostHogSessionManager.rotateSession() - - assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) - } - @Test internal fun `isSessionExceedingMaxDuration returns true after 24 hours`() { PostHogSessionManager.startSession() From 879275e44fda93b115766d2d4d230f1e7f701108 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:52:28 +0100 Subject: [PATCH 08/15] fix: use setDateProvider() after dateProvider was made private --- .../internal/PostHogLifecycleObserverIntegrationTest.kt | 8 ++++---- .../com/posthog/internal/PostHogSessionManagerTest.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 3ce052b3..6e2e94bc 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 @@ -44,7 +44,7 @@ internal class PostHogLifecycleObserverIntegrationTest { @AfterTest fun `tear down`() { PostHogSessionManager.isReactNative = false - PostHogSessionManager.dateProvider = PostHogDeviceDateProvider() + PostHogSessionManager.setDateProvider(PostHogDeviceDateProvider()) PostHogSessionManager.endSession() } @@ -160,7 +160,7 @@ internal class PostHogLifecycleObserverIntegrationTest { fun `onStart rotates session when session exceeds 24 hours`() { val baseTime = System.currentTimeMillis() val fakeDateProvider = FakeDateProviderForTest(baseTime) - PostHogSessionManager.dateProvider = fakeDateProvider + PostHogSessionManager.setDateProvider(fakeDateProvider) val config = PostHogAndroidConfig(API_KEY).apply { dateProvider = fakeDateProvider @@ -213,7 +213,7 @@ internal class PostHogLifecycleObserverIntegrationTest { fun `onStart does not rotate session when session is under 24 hours`() { val baseTime = System.currentTimeMillis() val fakeDateProvider = FakeDateProviderForTest(baseTime) - PostHogSessionManager.dateProvider = fakeDateProvider + PostHogSessionManager.setDateProvider(fakeDateProvider) val config = PostHogAndroidConfig(API_KEY).apply { dateProvider = fakeDateProvider @@ -251,7 +251,7 @@ internal class PostHogLifecycleObserverIntegrationTest { PostHogSessionManager.isReactNative = true val baseTime = System.currentTimeMillis() val fakeDateProvider = FakeDateProviderForTest(baseTime) - PostHogSessionManager.dateProvider = fakeDateProvider + PostHogSessionManager.setDateProvider(fakeDateProvider) val config = PostHogAndroidConfig(API_KEY).apply { dateProvider = fakeDateProvider diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index 07d29b2d..d4c2c760 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -114,7 +114,7 @@ internal class PostHogSessionManagerTest { @AfterTest internal fun cleanup() { PostHogSessionManager.isReactNative = false - PostHogSessionManager.dateProvider = PostHogDeviceDateProvider() + PostHogSessionManager.setDateProvider(PostHogDeviceDateProvider()) PostHogSessionManager.endSession() } } From a8c463523f841d9c1dbae621d3311018a7fbf7bb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 21 Apr 2026 13:47:47 +0100 Subject: [PATCH 09/15] fix: restart session replay after 24h rotation in background When the 24h session limit expires while the app is backgrounded, onStop ends the session and stops replay. The subsequent onStart was only calling startSession(), leaving replay disabled even when session replay was active before the rotation. Mirror the foreground-rotation branch by tracking whether replay was active prior to rotation and restarting it under the new session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PostHogLifecycleObserverIntegration.kt | 9 +++ .../java/com/posthog/android/PostHogFake.kt | 9 ++- ...PostHogLifecycleObserverIntegrationTest.kt | 75 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) 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 3b66192b..8f4859c4 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 @@ -11,6 +11,7 @@ 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 /** @@ -29,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 @@ -74,6 +76,11 @@ 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) { @@ -120,8 +127,10 @@ internal class PostHogLifecycleObserverIntegration( // Session has been active for longer than 24 hours, rotate to a new session if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { 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 { 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 cf1aafd5..948b7988 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -14,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) { } @@ -192,13 +195,17 @@ public class PostHogFake : PostHogInterface { } 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 6e2e94bc..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 @@ -209,6 +209,81 @@ internal class PostHogLifecycleObserverIntegrationTest { 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() From 28bc68233a6d079ceb8bebf108b326cda70c676e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 21 Apr 2026 23:47:52 +0100 Subject: [PATCH 10/15] fix: rotate session in PostHogSessionManager getter after 24h Mirrors the iOS pattern: getActiveSessionId() now checks expiry on every read and rotates (foreground) or clears (background) when the session has lived past SESSION_MAX_DURATION. Previously, rotation only fired on lifecycle transitions, so a continuously foregrounded app could ride a stale session id indefinitely. Adds setAppInBackground() toggled by the lifecycle observer and a setOnSessionIdChangedListener() registered in PostHog.setup() to notify the session replay handler when the getter rotates silently. Ports the iOS sessionRotatedAfterMaxSessionLength test plus sibling cases for bg-clear, RN skip, under-24h no-op, and listener firing. --- .../PostHogLifecycleObserverIntegration.kt | 12 +- posthog/api/posthog.api | 2 + posthog/src/main/java/com/posthog/PostHog.kt | 4 + .../posthog/internal/PostHogSessionManager.kt | 47 ++++++- .../internal/PostHogSessionManagerTest.kt | 132 ++++++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) 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 8f4859c4..4b0d3404 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 @@ -47,6 +47,7 @@ internal class PostHogLifecycleObserverIntegration( } override fun onStart(owner: LifecycleOwner) { + PostHogSessionManager.setAppInBackground(false) startSession() if (config.captureApplicationLifecycleEvents) { @@ -117,15 +118,20 @@ 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() - // Session has been active for longer than 24 hours, rotate to a new session - if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { + if (wasExpired) { cancelTask() val wasReplayActive = postHog?.isSessionReplayActive() == true postHog?.endSession() diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 709235f3..cd509033 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -893,7 +893,9 @@ public final class com/posthog/internal/PostHogSessionManager { 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 diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 82c6a68c..c84deb5c 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 { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 49d1acd3..794e942b 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -32,6 +32,28 @@ public object PostHogSessionManager { @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 @@ -82,9 +104,32 @@ public object PostHogSessionManager { private const val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours 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() + val expired = sessionStartedAt > 0L && (sessionStartedAt + SESSION_MAX_DURATION) <= now + if (expired) { + sessionChanged = true + if (isAppInBackground) { + sessionId = sessionIdNone + sessionStartedAt = 0L + tempSessionId = null + } else { + sessionId = TimeBasedEpochGenerator.generate() + sessionStartedAt = now + tempSessionId = sessionId + } + } else { + tempSessionId = sessionId + } + } + } + if (sessionChanged) { + onSessionIdChangedListener?.also { it.invoke() } } return tempSessionId } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index d4c2c760..975a72c8 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 @@ -111,10 +123,130 @@ internal class PostHogSessionManagerTest { assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(System.currentTimeMillis())) } + @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 under 24 hours`() { + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + + fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 23) + + 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() + } } From 165e5f295bea4f8977256f54a30b00fc3614c7f3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 12:36:03 +0100 Subject: [PATCH 11/15] fix: prefer caller-provided $session_id over getter in buildProperties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the iOS pattern: when properties already contains $session_id (e.g., session replay attaches it at frame-build time), use it directly instead of calling PostHogSessionManager.getActiveSessionId(). The getter can silently rotate the manager's session, but the caller's value wins downstream via putAll — so the rotation would be wasted. --- posthog/src/main/java/com/posthog/PostHog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index c84deb5c..ee03c198 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -437,8 +437,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) { From 516034cb8a95f99c645350cc506a6d3d5fee08c5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:12:54 +0100 Subject: [PATCH 12/15] test: cover caller-provided session_id; clean up session listener on close Adds the missing PostHog-level test verifying that a caller-provided $session_id wins over the session manager's value (the change in 165e5f2 had no integration test). Also clears the session listener in PostHog.close() to avoid leaking the PostHog instance via the process-singleton PostHogSessionManager, and simplifies a ?.also { it.invoke() } down to ?.invoke(). --- posthog/src/main/java/com/posthog/PostHog.kt | 2 ++ .../posthog/internal/PostHogSessionManager.kt | 2 +- .../src/test/java/com/posthog/PostHogTest.kt | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index ee03c198..830300a0 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -312,6 +312,8 @@ public class PostHog private constructor( featureFlagsCalled.clear() + PostHogSessionManager.setOnSessionIdChangedListener(null) + endSession() } catch (e: Throwable) { config?.logger?.log("Close failed: $e.") diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 794e942b..49a985dd 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -129,7 +129,7 @@ public object PostHogSessionManager { } } if (sessionChanged) { - onSessionIdChangedListener?.also { it.invoke() } + onSessionIdChangedListener?.invoke() } return tempSessionId } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 79b6052e..91b7afb2 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -833,6 +833,41 @@ 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() + + // Caller-provided id wins on the event + assertEquals(callerSessionId, theEvent.properties!!["\$session_id"]) + + // Manager state was not rotated by the getter — it's untouched + assertEquals(managerSessionId, PostHogSessionManager.getActiveSessionId()) + + sut.close() + } + @Test fun `capture uses generated distinctId if not given`() { val http = mockHttp() From 5c777b5831a45c9d5b748d1a89812d225306ce38 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:13:09 +0100 Subject: [PATCH 13/15] fix: stamp sessionStartedAt in setSessionId; restart replay on rotation Addresses two points from @ioannisj's review: - setSessionId now stamps sessionStartedAt so an externally-set session participates in the 24h expiry check. Without it, sessionStartedAt stayed 0 and the session would never expire. - PostHogReplayIntegration.onSessionIdChanged now restarts the recording when the session rotates silently (e.g., 24h getter rotation), so the new session emits fresh meta + full wireframe events. Previously the new session received only incremental events, leaving the replay viewer with no baseline to render. If the session was cleared (background expiry), recording stops outright since snapshots without $session_id are dropped anyway. --- .../android/replay/PostHogReplayIntegration.kt | 15 ++++++++++++++- .../com/posthog/internal/PostHogSessionManager.kt | 4 +++- .../posthog/internal/PostHogSessionManagerTest.kt | 11 +++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) 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..ebf5be1d 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 @@ -1662,7 +1662,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 +1679,18 @@ 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). + // Without this reset the new session would get only incremental events, + // leaving the replay viewer with no baseline to render. + if (currentSessionId == null) { + config.logger.log("[Session Replay] Session cleared. Stopping recording.") + stop() + } else { + config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") + stop() + start(resumeCurrent = false) + } } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 49a985dd..5a96a710 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -135,9 +135,11 @@ public object PostHogSessionManager { } public fun setSessionId(sessionId: UUID) { - // RN can only set its own session id directly synchronized(sessionLock) { 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 = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index 975a72c8..16f0cbb8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -123,6 +123,17 @@ internal class PostHogSessionManagerTest { assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(System.currentTimeMillis())) } + @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 From 7888e8a3531ac30819b80ada1fa03be1a47e326f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:56:11 +0100 Subject: [PATCH 14/15] fix: post replay restart to main thread; add test for rotation listener The replay restart from onSessionIdChanged calls start(resumeCurrent=false) which iterates a non-thread-safe WeakHashMap. The getter listener can fire from any thread that calls capture(), so post both stop and start to the main handler. Also adds a PostHogTest that verifies the listener actually wires through: forces an expired session via setSessionId + a backdated date provider, calls getSessionId(), and asserts the fake replay handler's onSessionIdChangedCalled flag flips. Drops a weak assertion from the caller-provided session_id test that was tautologically true. --- .../replay/PostHogReplayIntegration.kt | 12 +++-- .../src/test/java/com/posthog/PostHogTest.kt | 46 +++++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) 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 ebf5be1d..562b65b2 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 @@ -1681,15 +1681,17 @@ public class PostHogReplayIntegration( } } else if (isSessionReplayActive) { // Session rotated/cleared silently (e.g., 24h max duration via getter). - // Without this reset the new session would get only incremental events, - // leaving the replay viewer with no baseline to render. + // 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.") - stop() + mainHandler.handler.post { stop() } } else { config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") - stop() - start(resumeCurrent = false) + mainHandler.handler.post { + stop() + start(resumeCurrent = false) + } } } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 91b7afb2..a2406a86 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -859,15 +859,55 @@ internal class PostHogTest { val batch = serializer.deserialize(content.reader()) val theEvent = batch.batch.first() - // Caller-provided id wins on the event assertEquals(callerSessionId, theEvent.properties!!["\$session_id"]) - // Manager state was not rotated by the getter — it's untouched - assertEquals(managerSessionId, PostHogSessionManager.getActiveSessionId()) + 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() From 68e3e9a6519ee0493f49e3c45580247d160fe0fc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 14:31:53 +0100 Subject: [PATCH 15/15] fix: rotate or clear session after 30 minutes of inactivity Closes the parity gap with iOS PostHogSessionManager that ioannisj flagged. The Android session manager only rotated on background/ foreground transitions and on the 24h max-duration check; iOS also rotates after 30min of no user activity, regardless of fg/bg state. Manager changes: - New sessionActivityTimestamp field tracking last activity. - New touchSession() method mirroring iOS: rotates if idle past SESSION_INACTIVITY_DURATION (30min), else refreshes timestamp. No-op when backgrounded so background events don't keep a dead session alive. - getActiveSessionId() also checks inactivity (in iOS order: inactivity first, then 24h max). - Extracted isIdle/isMaxExpired/rotateLocked/clearLocked helpers to deduplicate the three places that compute expiry, and pulled the React Native check inside the lock to remove a TOCTOU race. Wiring (call sites for touchSession): - PostHog.capture(): touch at the start so any captured event counts as activity. iOS achieves this via UIEvent swizzling; Android lacks the equivalent so capture() is the safe fallback. - PostHogLifecycleObserverIntegration.onStart: touch after setAppInBackground(false) (mirror iOS lifecycle hook). - PostHogReplayIntegration.onTouchEventListener: touch on every intercepted touch (closest Android equivalent to iOS UIEvents). The lifecycle observer's existing 30min Timer is now overlapping with the manager's bg-clear path; kept as defense-in-depth for backgrounded apps that don't fire any events. Tests: added 7 unit tests for touchSession behavior, foreground inactivity rotation, background inactivity clear, and the no-op-while-backgrounded guarantee. Added a PostHog-level test verifying that capture() rotates an idle session via touchSession before reading the session id. Updated the existing "under 24 hours" test to keep the session active with periodic touches so the new inactivity check doesn't break it. --- .../PostHogLifecycleObserverIntegration.kt | 8 ++ .../replay/PostHogReplayIntegration.kt | 2 + posthog/api/posthog.api | 1 + posthog/src/main/java/com/posthog/PostHog.kt | 4 + .../posthog/internal/PostHogSessionManager.kt | 107 +++++++++++---- .../src/test/java/com/posthog/PostHogTest.kt | 31 +++++ .../internal/PostHogSessionManagerTest.kt | 128 +++++++++++++++++- 7 files changed, 249 insertions(+), 32 deletions(-) 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 4b0d3404..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 @@ -48,6 +48,8 @@ internal class PostHogLifecycleObserverIntegration( override fun onStart(owner: LifecycleOwner) { PostHogSessionManager.setAppInBackground(false) + // Foregrounding counts as activity (mirror iOS onDidBecomeActive). + PostHogSessionManager.touchSession() startSession() if (config.captureApplicationLifecycleEvents) { @@ -105,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 = 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 562b65b2..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) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index cd509033..4af8c404 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -899,6 +899,7 @@ public final class com/posthog/internal/PostHogSessionManager { 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 830300a0..41b565b2 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -488,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 5a96a710..88e6e844 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -29,6 +29,12 @@ public object PostHogSessionManager { */ 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 @@ -55,28 +61,21 @@ public object PostHogSessionManager { } public fun startSession() { - if (isReactNative) { - // RN manages its own session - return - } - synchronized(sessionLock) { - if (sessionId == sessionIdNone) { - sessionId = TimeBasedEpochGenerator.generate() - sessionStartedAt = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() - } + // 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() { - if (isReactNative) { - // RN manages its own session - return - } - synchronized(sessionLock) { - sessionId = sessionIdNone - sessionStartedAt = 0L + if (isReactNative) return + clearLocked() } } @@ -96,12 +95,35 @@ public object PostHogSessionManager { */ public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { synchronized(sessionLock) { - return sessionStartedAt > 0L && - (sessionStartedAt + SESSION_MAX_DURATION) <= currentTimeMillis + 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 @@ -111,18 +133,16 @@ public object PostHogSessionManager { tempSessionId = if (sessionId != sessionIdNone) sessionId else null } else { val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() - val expired = sessionStartedAt > 0L && (sessionStartedAt + SESSION_MAX_DURATION) <= now - if (expired) { + // Check inactivity first, then max-duration (mirror iOS order). + if (isIdle(now) || isMaxExpired(now)) { sessionChanged = true - if (isAppInBackground) { - sessionId = sessionIdNone - sessionStartedAt = 0L - tempSessionId = null - } else { - sessionId = TimeBasedEpochGenerator.generate() - sessionStartedAt = now - tempSessionId = sessionId - } + tempSessionId = + if (isAppInBackground) { + clearLocked() + null + } else { + rotateLocked(now) + } } else { tempSessionId = sessionId } @@ -134,12 +154,39 @@ public object PostHogSessionManager { 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) { 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 = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + 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 a2406a86..a4a84113 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -864,6 +864,37 @@ internal class PostHogTest { 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() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index 16f0cbb8..da3f20d8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -123,6 +123,121 @@ internal class PostHogSessionManagerTest { 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 @@ -185,7 +300,7 @@ internal class PostHogSessionManagerTest { } @Test - internal fun `getActiveSessionId does not rotate under 24 hours`() { + 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) @@ -193,7 +308,16 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.startSession() val firstSessionId = PostHogSessionManager.getActiveSessionId() - fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 23) + // 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()) }