From 9fcded2ba899a7a50cc1942ef71e62f4a3b38b71 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:42:13 +0100 Subject: [PATCH 1/8] 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 fcb456a4..d12f2520 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 48315076..b00c5310 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -875,8 +875,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 87ecff87721bc906d7aef536d294e28a729355ba Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:46:09 +0100 Subject: [PATCH 2/8] 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 d12f2520..8679f1d6 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 60b3b9c5b6592e89cd36f73b476c2e6dd2841fe8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:47:58 +0100 Subject: [PATCH 3/8] 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 ffe480025c8d65ee8ec88c0374ef73ee96cf15b2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:48:12 +0100 Subject: [PATCH 4/8] 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 419525bd593c6e1e78cfce842cd107b34290b2ee Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:31:46 +0100 Subject: [PATCH 5/8] 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 8679f1d6..c82a41a2 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 ec6bc86b..cf987708 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 @@ -171,13 +172,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 b00c5310..12db00d3 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -879,6 +879,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 05e71c28f0d08af70dbc68bf23b7d817c959d287 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:32:09 +0100 Subject: [PATCH 6/8] 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 c2e78b18012d5963d72a2f70cb9a4d68f4a0a02e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:41:15 +0100 Subject: [PATCH 7/8] 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 babf4dde..357b10b8 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -880,12 +880,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 0d757c9515cff93edbf724fbde040761f3a67b89 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:52:28 +0100 Subject: [PATCH 8/8] 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() } }