From b2a522d635f84462116c3bf46f844c301b90eb41 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:42:13 +0100 Subject: [PATCH 01/25] 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 000000000..5783e05a2 --- /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 7fd4d5f76..48dae7c24 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 b5b9779c7..a64c7290a 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 f6422522f..86b6a0a09 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -896,8 +896,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 4f0ee1ee7..0f60ca34f 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 fc9317635..0c14cb681 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 6cfd32b95465f44ea4962cd0b3bea5f236cf6cf1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:46:09 +0100 Subject: [PATCH 02/25] 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 48dae7c24..beb355999 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 a64c7290a..3ce052b3f 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 d43174635d0bd0d2ab1e8f04fef8f9df9e847154 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:47:58 +0100 Subject: [PATCH 03/25] 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 5783e05a2..09608a689 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 faa882bf008bd37650fae4c6ac38e8cbd34c57af Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 26 Mar 2026 17:48:12 +0100 Subject: [PATCH 04/25] 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 09608a689..a6434a8cf 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 b01bd5fe5d42c9580c8024bccdf3fbb67a54eb60 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:31:46 +0100 Subject: [PATCH 05/25] 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 000000000..32ffc8f9c --- /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 beb355999..3b66192b6 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 1960d8086..cf1aafd56 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 eacc579ad..a061438f5 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt @@ -41,8 +41,8 @@ class MyApp : Application() { sessionReplayConfig.maskAllImages = false sessionReplayConfig.captureLogcat = true sessionReplayConfig.screenshot = true - surveys = true - errorTrackingConfig.autoCapture = true + surveys = false + errorTrackingConfig.autoCapture = false } PostHogAndroid.setup(this, config) } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 86b6a0a09..bb75467cb 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -900,6 +900,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 0f60ca34f..6fcc21818 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 0c14cb681..dba31189d 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 ff9b2de322f003a7f95a6bf5f61144ded0358850 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:32:09 +0100 Subject: [PATCH 06/25] 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 32ffc8f9c..000000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,3 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainVendor=amazon -toolchainVersion=17 From 3067a996e256a9965d0af9cfb00fef1591faf6a8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:41:15 +0100 Subject: [PATCH 07/25] 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 2505fb131..119f4f8ff 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 bb75467cb..3015578b1 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -896,12 +896,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 6fcc21818..49d1acd39 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 dba31189d..07d29b2d9 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 31c41dfe78e8c2b58bb7b03c681b6c3e79cae731 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 27 Mar 2026 16:52:28 +0100 Subject: [PATCH 08/25] 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 3ce052b3f..6e2e94bc9 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 07d29b2d9..d4c2c760d 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 2ab7e4316e2d21de8942f8a821321ab62d816589 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 21 Apr 2026 13:47:47 +0100 Subject: [PATCH 09/25] 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 3b66192b6..8f4859c40 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 cf1aafd56..948b7988a 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 6e2e94bc9..ea1f48658 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 aac7323e04154f804637b89ff1b85987b1f58f57 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 21 Apr 2026 23:47:52 +0100 Subject: [PATCH 10/25] 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 8f4859c40..4b0d3404a 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 3015578b1..44db848d0 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -900,7 +900,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 82c6a68ce..c84deb5cf 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 49d1acd39..794e942bc 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 d4c2c760d..975a72c84 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 114616baad8efdf7d3beb5ecdde71e03d15c71c4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 12:36:03 +0100 Subject: [PATCH 11/25] 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 c84deb5cf..ee03c198b 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 53182b3c20950aae67d9fc2e2b26e4dd0165343c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:12:54 +0100 Subject: [PATCH 12/25] 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 ee03c198b..830300a0c 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 794e942bc..49a985dde 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 79b6052e8..91b7afb27 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 396fc377a1903b4366c2e2bc46e14f10eb08ac59 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:13:09 +0100 Subject: [PATCH 13/25] 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 54a8170be..ebf5be1d1 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 49a985dde..5a96a7103 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 975a72c84..16f0cbb88 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 d230339d7cd104b754702325895e707243e05e41 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 13:56:11 +0100 Subject: [PATCH 14/25] 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 ebf5be1d1..562b65b2c 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 91b7afb27..a2406a867 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 e4206b7ceaf5f3651c2280e39a9d87e17ed2a48a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 22 Apr 2026 14:31:53 +0100 Subject: [PATCH 15/25] 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 4b0d3404a..ec778a25e 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 562b65b2c..8e23b9049 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 44db848d0..e03f90004 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -906,6 +906,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 830300a0c..41b565b2b 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 5a96a7103..88e6e844c 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 a2406a867..a4a841133 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 16f0cbb88..da3f20d83 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()) } From a367781162876331fffe09ff3d1e510a9dd4107a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 1 May 2026 13:53:00 -0400 Subject: [PATCH 16/25] fix: address PR feedback and align session-id behavior with iOS - setSessionId no-ops when id is unchanged (avoids resetting the 24h / 30-min clocks when RN re-asserts the same id on every event) - add peekSessionId for read-only callers; replay listener uses it to avoid re-entering the mutating getter from inside onSessionIdChanged - add restartSessionReplay (sampling-aware, no rotation) so a silently rotated session re-evaluates sampling instead of bypassing it - decouple touch-activity tracking from session replay: new PostHogTouchActivityIntegration wires touchSession independently so apps with replay disabled still get touch-driven inactivity rotation - isAppInBackground defaults to true (iOS parity); first onStart flips it to foreground - touchSession on background entry, mirroring iOS onDidEnterBackground - onSessionIdChanged listener now fires from startSession, endSession, and setSessionId (when state actually changes), so replay reacts to every state change, not only silent getter rotations Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/posthog/android/PostHogAndroid.kt | 2 + .../PostHogLifecycleObserverIntegration.kt | 18 ++-- .../PostHogTouchActivityIntegration.kt | 97 +++++++++++++++++++ .../replay/PostHogReplayIntegration.kt | 38 +++++--- .../java/com/posthog/android/PostHogFake.kt | 12 +++ posthog/api/posthog.api | 5 + posthog/src/main/java/com/posthog/PostHog.kt | 25 +++++ .../main/java/com/posthog/PostHogInterface.kt | 13 +++ .../posthog/internal/PostHogSessionManager.kt | 43 +++++++- .../src/test/java/com/posthog/PostHogTest.kt | 5 +- .../internal/PostHogSessionManagerTest.kt | 53 +++++++++- 11 files changed, 279 insertions(+), 32 deletions(-) create mode 100644 posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt 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 119f4f8ff..001ba349b 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -15,6 +15,7 @@ import com.posthog.android.internal.PostHogAppInstallIntegration import com.posthog.android.internal.PostHogLifecycleObserverIntegration import com.posthog.android.internal.PostHogMetaPropertiesApplier import com.posthog.android.internal.PostHogSharedPreferences +import com.posthog.android.internal.PostHogTouchActivityIntegration import com.posthog.android.internal.appContext import com.posthog.android.internal.getPackageInfo import com.posthog.android.internal.versionCodeCompat @@ -132,6 +133,7 @@ public class PostHogAndroid private constructor() { val mainHandler = MainHandler() config.addIntegration(PostHogReplayIntegration(context, config, mainHandler)) + config.addIntegration(PostHogTouchActivityIntegration(config)) config.addIntegration(PostHogLogCatIntegration(config)) if (context is Application) { if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay) { diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt index ec778a25e..468566142 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 @@ -85,16 +85,11 @@ internal class PostHogLifecycleObserverIntegration( postHog?.startSessionReplay(resumeCurrent = true) } } else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { - // Session has been active for longer than 24 hours, rotate to a new session - if (postHog?.isSessionReplayActive() == true) { - postHog?.stopSessionReplay() - - // startSessionReplay will rotate the session id internally - postHog?.startSessionReplay(resumeCurrent = false) - } else { - postHog?.endSession() - postHog?.startSession() - } + // Session has been active for longer than 24 hours; rotate to a new session and + // (if replay is enabled) re-evaluate sampling on the fresh id. + postHog?.endSession() + postHog?.startSession() + postHog?.restartSessionReplay() } this.lastUpdatedSession.set(currentTimeMillis) } @@ -132,6 +127,9 @@ internal class PostHogLifecycleObserverIntegration( // zeroing sessionStartedAt so the 24h check below would miss it. val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis) + // Backgrounding counts as activity (mirror iOS onDidEnterBackground). Touch before + // flipping the bg flag so touchSession doesn't no-op on the fg→bg transition. + PostHogSessionManager.touchSession() PostHogSessionManager.setAppInBackground(true) if (config.captureApplicationLifecycleEvents) { postHog?.capture("Application Backgrounded") diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt new file mode 100644 index 000000000..d45af96a9 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt @@ -0,0 +1,97 @@ +package com.posthog.android.internal + +import android.os.Build +import android.view.View +import com.posthog.PostHogIntegration +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.PostHogSessionManager +import curtains.Curtains +import curtains.OnRootViewsChangedListener +import curtains.TouchEventInterceptor +import curtains.phoneWindow +import curtains.touchEventInterceptors + +/** + * Marks user touches as session activity by calling [PostHogSessionManager.touchSession] + * on every dispatched MotionEvent. Mirrors iOS UIEvent swizzling. + * + * Decoupled from [com.posthog.android.replay.PostHogReplayIntegration] so apps that have + * session replay disabled (or sampled out) still get touch-driven inactivity rotation, + * which keeps session-id rotation behavior consistent regardless of replay state. + */ +internal class PostHogTouchActivityIntegration( + private val config: PostHogAndroidConfig, +) : PostHogIntegration { + private companion object { + @Volatile + private var integrationInstalled = false + } + + private val touchInterceptor = + TouchEventInterceptor { motionEvent, dispatch -> + try { + PostHogSessionManager.touchSession() + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration touchSession failed: $e.") + } + dispatch(motionEvent) + } + + private val attachedWindows = mutableSetOf() + + private val onRootViewsChangedListener = + OnRootViewsChangedListener { view, added -> + try { + val window = view.phoneWindow ?: return@OnRootViewsChangedListener + if (added) { + if (attachedWindows.add(view)) { + window.touchEventInterceptors += touchInterceptor + } + } else { + if (attachedWindows.remove(view)) { + window.touchEventInterceptors -= touchInterceptor + } + } + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration root view changed failed: $e.") + } + } + + override fun install(postHog: PostHogInterface) { + if (integrationInstalled || !isSupported()) { + return + } + integrationInstalled = true + try { + Curtains.rootViews.forEach { view -> + view.phoneWindow?.let { window -> + if (attachedWindows.add(view)) { + window.touchEventInterceptors += touchInterceptor + } + } + } + Curtains.onRootViewsChangedListeners += onRootViewsChangedListener + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration install failed: $e.") + } + } + + override fun uninstall() { + try { + Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener + attachedWindows.forEach { view -> + view.phoneWindow?.let { window -> + window.touchEventInterceptors -= touchInterceptor + } + } + attachedWindows.clear() + } catch (e: Throwable) { + config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") + } finally { + integrationInstalled = false + } + } + + private fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O +} diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 8e23b9049..af6aafe96 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,8 +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() + // Note: user-activity tracking (PostHogSessionManager.touchSession) lives in + // PostHogTouchActivityIntegration so it runs regardless of replay state. try { val state = dispatch(motionEvent) @@ -1666,11 +1666,14 @@ 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, or re-initializes recording so the * new session gets fresh meta + full wireframe events. + * + * Uses peekSessionId() (not getSessionId()) so reading the new id can't re-trigger the + * mutating getter and recurse back into this listener. */ override fun onSessionIdChanged() { val postHog = this.postHog ?: return - val currentSessionId = postHog.getSessionId()?.toString() + val currentSessionId = PostHogSessionManager.peekSessionId()?.toString() val triggers = config.remoteConfigHolder?.getEventTriggers() val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId } @@ -1681,20 +1684,27 @@ public class PostHogReplayIntegration( config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.") stop() } - } else if (isSessionReplayActive) { - // Session rotated/cleared silently (e.g., 24h max duration via getter). - // Posting to main: getter can be invoked from any thread that calls capture(), - // and start(resumeCurrent = false) iterates a non-thread-safe WeakHashMap. - if (currentSessionId == null) { + return + } + + // Session rotated/cleared silently (e.g. 30-min idle or 24h max duration via getter). + // Posting to main: getter can be invoked from any thread that calls capture(), + // and start(resumeCurrent = false) iterates a non-thread-safe WeakHashMap. + if (currentSessionId == null) { + if (isSessionReplayActive) { config.logger.log("[Session Replay] Session cleared. Stopping recording.") mainHandler.handler.post { stop() } - } else { - config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") - mainHandler.handler.post { - stop() - start(resumeCurrent = false) - } } + return + } + + // React even when replay is currently inactive: a previous session may have been + // sampled out; the new session may now pass. restartSessionReplay handles the + // stop-if-active + sampling check + clean restart (without re-rotating the session, + // which would cause double-rotation on top of the silent rotation that fired this). + config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") + mainHandler.handler.post { + postHog.restartSessionReplay() } } 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 948b7988a..e569717e0 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -17,6 +17,7 @@ public class PostHogFake : PostHogInterface { public var sessionReplayActive: Boolean = false public var startSessionReplayCalls: Int = 0 public var stopSessionReplayCalls: Int = 0 + public var restartSessionReplayCalls: Int = 0 override fun setup(config: T) { } @@ -208,6 +209,17 @@ public class PostHogFake : PostHogInterface { sessionReplayActive = false } + override fun restartSessionReplay() { + restartSessionReplayCalls++ + // mirror the real impl: stop then start (we don't model sampling here) + if (sessionReplayActive) { + sessionReplayActive = false + stopSessionReplayCalls++ + } + sessionReplayActive = true + startSessionReplayCalls++ + } + override fun getSessionId(): UUID? { return null } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index e03f90004..ddb6d39e8 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -52,6 +52,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V + public fun restartSessionReplay ()V public fun screen (Ljava/lang/String;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -96,6 +97,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V public final fun resetSharedInstance ()V + public fun restartSessionReplay ()V public fun screen (Ljava/lang/String;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -332,6 +334,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public abstract fun resetPersonPropertiesForFlags (Z)V + public abstract fun restartSessionReplay ()V public abstract fun screen (Ljava/lang/String;Ljava/util/Map;)V public abstract fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public abstract fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -355,6 +358,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V + public static fun restartSessionReplay (Lcom/posthog/PostHogInterface;)V public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun setGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ZILjava/lang/Object;)V public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V @@ -900,6 +904,7 @@ 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 peekSessionId ()Ljava/util/UUID; public final fun setAppInBackground (Z)V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V public final fun setOnSessionIdChangedListener (Lkotlin/jvm/functions/Function0;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 41b565b2b..65b596457 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1441,6 +1441,27 @@ public class PostHog private constructor( } } + override fun restartSessionReplay() { + if (!isEnabled()) { + return + } + if (!isSessionReplayFlagEnabled()) { + return + } + sessionReplayHandler?.let { + // Stop any in-progress recording so the new session starts with cleared snapshot state. + if (it.isActive()) { + it.stop() + } + // Re-evaluate sampling for the (already-current) session id; if the prior session + // was sampled out, this one might pass, and vice versa. + if (!shouldRecordSession()) { + return + } + it.start(false) + } + } + override fun stopSessionReplay() { if (!isEnabled()) { return @@ -1727,6 +1748,10 @@ public class PostHog private constructor( shared.stopSessionReplay() } + override fun restartSessionReplay() { + shared.restartSessionReplay() + } + override fun getSessionId(): UUID? { return shared.getSessionId() } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 2a6948581..31231c3af 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -216,6 +216,19 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun stopSessionReplay() + /** + * Restarts session replay for the current session id. Stops any in-progress recording, + * re-runs the sampling decision for the (already-current) session, and resumes recording + * with fresh meta + full snapshot keyframes if sampling passes. + * + * Unlike [startSessionReplay] (resumeCurrent = false), this does NOT rotate the session id — + * use it from places where the session has already changed (e.g. silent rotation from + * [PostHogSessionManager.getActiveSessionId]) so we don't rotate twice. + */ + @PostHogInternal + public fun restartSessionReplay() { + } + /** * Returns the session Id if a session is active */ diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 88e6e844c..2e853d734 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -38,8 +38,11 @@ public object PostHogSessionManager { @Volatile public var isReactNative: Boolean = false + // Default to background to mirror iOS: until the first lifecycle onStart fires we don't + // know we're foregrounded, and treating the process as bg means an expired session is + // cleared (returns null) rather than silently rotated before any UI is visible. @Volatile - private var isAppInBackground: Boolean = false + private var isAppInBackground: Boolean = true @Volatile private var onSessionIdChangedListener: (() -> Unit)? = null @@ -61,21 +64,31 @@ public object PostHogSessionManager { } public fun startSession() { + var sessionChanged = false synchronized(sessionLock) { // Re-check inside the lock — RN flag is set once at setup but checking // here keeps state consistent with the lock's invariants. - if (isReactNative || sessionId != sessionIdNone) return + if (isReactNative || sessionId != sessionIdNone) return@synchronized val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() sessionId = TimeBasedEpochGenerator.generate() sessionStartedAt = now sessionActivityTimestamp = now + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } } public fun endSession() { + var sessionChanged = false synchronized(sessionLock) { - if (isReactNative) return + if (isReactNative || sessionId == sessionIdNone) return@synchronized clearLocked() + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } } @@ -180,13 +193,21 @@ public object PostHogSessionManager { } public fun setSessionId(sessionId: UUID) { + var sessionChanged = false synchronized(sessionLock) { + // Only stamp on a real change; re-asserting the same id (e.g. RN syncing on every + // event) shouldn't reset the 24h clock or the inactivity timer. + // NOTE: this is an Android-specific divergence from iOS, where setSessionId always + // re-stamps. Keeping per Ioannis review on PR #494. + if (this.sessionId == sessionId) return@synchronized val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() this.sessionId = sessionId - // Stamp the start so an externally-set session participates in the 24h - // expiry check; without it sessionStartedAt stays 0 and never expires. sessionStartedAt = now sessionActivityTimestamp = now + sessionChanged = true + } + if (sessionChanged) { + onSessionIdChangedListener?.invoke() } } @@ -197,4 +218,16 @@ public object PostHogSessionManager { } return active } + + /** + * Read-only sibling of [getActiveSessionId]: returns the current session id without + * running the inactivity / max-duration checks, so callers reacting to a session-id + * change (e.g. PostHogReplayIntegration.onSessionIdChanged) can inspect the new id + * without risking a re-entrant rotation that would re-fire the listener. + */ + public fun peekSessionId(): UUID? { + synchronized(sessionLock) { + return if (sessionId == sessionIdNone) null else sessionId + } + } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index a4a841133..ee514b840 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -872,6 +872,8 @@ internal class PostHogTest { // 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. + // Background defaults to true (mirrors iOS); flip to fg so touchSession isn't a no-op. + PostHogSessionManager.setAppInBackground(false) val realNow = System.currentTimeMillis() val fakeDate = TestDateProvider(realNow - (1000L * 60 * 31)) PostHogSessionManager.setDateProvider(fakeDate) @@ -904,7 +906,8 @@ internal class PostHogTest { // 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. + // expiry check trips. Foreground so the getter rotates instead of clearing. + PostHogSessionManager.setAppInBackground(false) val twentyFiveHoursMs = 25L * 60 * 60 * 1000 val realNow = System.currentTimeMillis() val fakeDate = TestDateProvider(realNow - twentyFiveHoursMs) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index da3f20d83..01e7389b5 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -328,10 +328,12 @@ internal class PostHogSessionManagerTest { val fakeDate = FakeDateProvider(baseTime) PostHogSessionManager.setDateProvider(fakeDate) + // Wire the listener AFTER startSession so the explicit start-fire isn't counted; we + // want to measure only the silent rotation triggered by the getter. + PostHogSessionManager.startSession() var callCount = 0 PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } - PostHogSessionManager.startSession() PostHogSessionManager.getActiveSessionId() // no rotation yet assertEquals(0, callCount) @@ -350,10 +352,10 @@ internal class PostHogSessionManagerTest { val fakeDate = FakeDateProvider(baseTime) PostHogSessionManager.setDateProvider(fakeDate) + PostHogSessionManager.startSession() var callCount = 0 PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } - PostHogSessionManager.startSession() PostHogSessionManager.setAppInBackground(true) fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 @@ -361,6 +363,53 @@ internal class PostHogSessionManagerTest { assertEquals(1, callCount) } + @Test + internal fun `startSession fires listener when a new session is created`() { + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.startSession() + assertEquals(1, callCount) + + // Re-asserting startSession on an already-active session is a no-op and must not refire. + PostHogSessionManager.startSession() + assertEquals(1, callCount) + } + + @Test + internal fun `endSession fires listener once when an active session is cleared`() { + PostHogSessionManager.startSession() + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.endSession() + assertEquals(1, callCount) + + // No active session → no fire. + PostHogSessionManager.endSession() + assertEquals(1, callCount) + } + + @Test + internal fun `setSessionId fires listener only when the id actually changes`() { + val firstId = UUID.randomUUID() + val sameId = firstId + val secondId = UUID.randomUUID() + + var callCount = 0 + PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } + + PostHogSessionManager.setSessionId(firstId) + assertEquals(1, callCount) + + // Re-asserting the same id (e.g. RN syncing on every event) must not refire. + PostHogSessionManager.setSessionId(sameId) + assertEquals(1, callCount) + + PostHogSessionManager.setSessionId(secondId) + assertEquals(2, callCount) + } + @AfterTest internal fun cleanup() { PostHogSessionManager.isReactNative = false From a7fed1d24c2aa77d8faafb3db8c51c9b8523fd1f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 1 May 2026 14:21:14 -0400 Subject: [PATCH 17/25] chore: address review feedback and trim narrative comments - drop redundant attachedWindows tracking in touch integration; rely on Curtains rootViews and per-window interceptor list as the source of truth - narrow getSessionStartedAt and setOnSessionIdChangedListener to internal - add setSessionId(sameId) no-op test - add smoke tests for PostHogTouchActivityIntegration - note isAppInBackground default change in changeset - trim narrative and iOS-parity comments throughout; keep only WHY-explaining comments Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/gentle-clouds-drift.md | 2 +- .../PostHogLifecycleObserverIntegration.kt | 33 +++++++------- .../PostHogTouchActivityIntegration.kt | 22 ++++------ .../replay/PostHogReplayIntegration.kt | 22 +++------- .../java/com/posthog/android/PostHogFake.kt | 7 ++- ...PostHogLifecycleObserverIntegrationTest.kt | 33 ++------------ .../PostHogTouchActivityIntegrationTest.kt | 43 ++++++++++++++++++ posthog/api/posthog.api | 2 - posthog/src/main/java/com/posthog/PostHog.kt | 9 ++-- .../main/java/com/posthog/PostHogInterface.kt | 11 +++-- .../posthog/internal/PostHogSessionManager.kt | 39 ++++++---------- .../src/test/java/com/posthog/PostHogTest.kt | 2 +- .../internal/PostHogSessionManagerTest.kt | 44 ++++++++++++------- 13 files changed, 135 insertions(+), 134 deletions(-) create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt diff --git a/.changeset/gentle-clouds-drift.md b/.changeset/gentle-clouds-drift.md index a6434a8cf..67e89c502 100644 --- a/.changeset/gentle-clouds-drift.md +++ b/.changeset/gentle-clouds-drift.md @@ -3,4 +3,4 @@ 'posthog-android': patch --- -Enforce 24-hour maximum session duration with automatic session rotation +Enforce 24-hour maximum session duration and 30-minute inactivity rotation with automatic session rotation, mirroring iOS. Note: `PostHogSessionManager.isAppInBackground` now defaults to `true` until the first lifecycle `onStart` flips it; downstream wrappers (Flutter, RN) that exercise the manager directly in tests may need to call `setAppInBackground(false)` to simulate a foregrounded process. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt index 468566142..ade1900aa 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,7 +48,8 @@ internal class PostHogLifecycleObserverIntegration( override fun onStart(owner: LifecycleOwner) { PostHogSessionManager.setAppInBackground(false) - // Foregrounding counts as activity (mirror iOS onDidBecomeActive). + // Foregrounding counts as activity so an idle session rotates here, not on the + // first capture after foregrounding. PostHogSessionManager.touchSession() startSession() @@ -79,14 +80,12 @@ 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 + // Resume replay if it was active when the previous onStop tore it down for a + // 24h rotation; otherwise the new session would have no recording. 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 and - // (if replay is enabled) re-evaluate sampling on the fresh id. postHog?.endSession() postHog?.startSession() postHog?.restartSessionReplay() @@ -102,12 +101,9 @@ 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. + // Backgrounded apps may fire no events, so the getter's idle check never runs; + // this timer guarantees the session ends after 30 min of bg per the + // PostHogInterface.endSession docstring. synchronized(timerLock) { cancelTask() timerTask = @@ -122,13 +118,13 @@ 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. + // Capture expiry before flipping the bg flag: once bg=true, getActiveSessionId + // would clear an expired session and zero sessionStartedAt, hiding it from the + // wasExpired branch below. val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis) - // Backgrounding counts as activity (mirror iOS onDidEnterBackground). Touch before - // flipping the bg flag so touchSession doesn't no-op on the fg→bg transition. + // Touch while still foregrounded so the activity timestamp moves forward; doing + // this after setAppInBackground(true) would no-op. PostHogSessionManager.touchSession() PostHogSessionManager.setAppInBackground(true) if (config.captureApplicationLifecycleEvents) { @@ -136,14 +132,15 @@ internal class PostHogLifecycleObserverIntegration( } postHog?.flush() - // Session has been active for longer than 24 hours, rotate to a new session if (wasExpired) { cancelTask() val wasReplayActive = postHog?.isSessionReplayActive() == true postHog?.endSession() + // Synchronous stop guarantees replay is torn down before the process suspends; + // the listener-driven path posts to main and may not run in time. postHog?.stopSessionReplay() replayActiveBeforeRotation.set(wasReplayActive) - // Reset so the next onStart knows to create a fresh session + // Zeroing forces the next onStart into the "create a fresh session" branch. lastUpdatedSession.set(0L) } else { lastUpdatedSession.set(currentTimeMillis) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt index d45af96a9..459b9985b 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogTouchActivityIntegration.kt @@ -1,7 +1,6 @@ package com.posthog.android.internal import android.os.Build -import android.view.View import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig @@ -14,11 +13,11 @@ import curtains.touchEventInterceptors /** * Marks user touches as session activity by calling [PostHogSessionManager.touchSession] - * on every dispatched MotionEvent. Mirrors iOS UIEvent swizzling. + * on every dispatched MotionEvent. * - * Decoupled from [com.posthog.android.replay.PostHogReplayIntegration] so apps that have - * session replay disabled (or sampled out) still get touch-driven inactivity rotation, - * which keeps session-id rotation behavior consistent regardless of replay state. + * Decoupled from session replay so apps with replay disabled or sampled out still get + * touch-driven inactivity rotation; otherwise session-id rotation behaviour would depend + * on whether the user happened to be sampled, which session metrics rely on being stable. */ internal class PostHogTouchActivityIntegration( private val config: PostHogAndroidConfig, @@ -38,20 +37,16 @@ internal class PostHogTouchActivityIntegration( dispatch(motionEvent) } - private val attachedWindows = mutableSetOf() - private val onRootViewsChangedListener = OnRootViewsChangedListener { view, added -> try { val window = view.phoneWindow ?: return@OnRootViewsChangedListener if (added) { - if (attachedWindows.add(view)) { + if (touchInterceptor !in window.touchEventInterceptors) { window.touchEventInterceptors += touchInterceptor } } else { - if (attachedWindows.remove(view)) { - window.touchEventInterceptors -= touchInterceptor - } + window.touchEventInterceptors -= touchInterceptor } } catch (e: Throwable) { config.logger.log("PostHogTouchActivityIntegration root view changed failed: $e.") @@ -66,7 +61,7 @@ internal class PostHogTouchActivityIntegration( try { Curtains.rootViews.forEach { view -> view.phoneWindow?.let { window -> - if (attachedWindows.add(view)) { + if (touchInterceptor !in window.touchEventInterceptors) { window.touchEventInterceptors += touchInterceptor } } @@ -80,12 +75,11 @@ internal class PostHogTouchActivityIntegration( override fun uninstall() { try { Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener - attachedWindows.forEach { view -> + Curtains.rootViews.forEach { view -> view.phoneWindow?.let { window -> window.touchEventInterceptors -= touchInterceptor } } - attachedWindows.clear() } catch (e: Throwable) { config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") } finally { 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 af6aafe96..0ab26c7c5 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,9 +276,6 @@ public class PostHogReplayIntegration( private val onTouchEventListener = TouchEventInterceptor { motionEvent, dispatch -> val timestamp = config.dateProvider.currentTimeMillis() - // Note: user-activity tracking (PostHogSessionManager.touchSession) lives in - // PostHogTouchActivityIntegration so it runs regardless of replay state. - try { val state = dispatch(motionEvent) try { @@ -1664,21 +1661,18 @@ 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, or re-initializes recording so the + * and the new session hasn't been activated yet, or re-initialises recording so the * new session gets fresh meta + full wireframe events. - * - * Uses peekSessionId() (not getSessionId()) so reading the new id can't re-trigger the - * mutating getter and recurse back into this listener. */ override fun onSessionIdChanged() { val postHog = this.postHog ?: return + // Read-only: getActiveSessionId() can rotate the session and would re-fire this listener. val currentSessionId = PostHogSessionManager.peekSessionId()?.toString() val triggers = config.remoteConfigHolder?.getEventTriggers() val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId } - // If triggers are configured and this session hasn't been activated, stop the integration if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) { if (isSessionReplayActive) { config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.") @@ -1687,9 +1681,8 @@ public class PostHogReplayIntegration( return } - // Session rotated/cleared silently (e.g. 30-min idle or 24h max duration via getter). - // Posting to main: getter can be invoked from any thread that calls capture(), - // and start(resumeCurrent = false) iterates a non-thread-safe WeakHashMap. + // The listener can fire from any thread that calls capture(); replay state writes + // (snapshot WeakHashMap, isSessionReplayActive) must happen on main. if (currentSessionId == null) { if (isSessionReplayActive) { config.logger.log("[Session Replay] Session cleared. Stopping recording.") @@ -1698,10 +1691,9 @@ public class PostHogReplayIntegration( return } - // React even when replay is currently inactive: a previous session may have been - // sampled out; the new session may now pass. restartSessionReplay handles the - // stop-if-active + sampling check + clean restart (without re-rotating the session, - // which would cause double-rotation on top of the silent rotation that fired this). + // Run regardless of isSessionReplayActive: the prior session may have been sampled out + // and the new one may now pass. restartSessionReplay re-checks sampling without rotating + // the session id (which would double-rotate on top of the silent rotation that fired us). config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") mainHandler.handler.post { postHog.restartSessionReplay() 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 e569717e0..af108130f 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -209,9 +209,14 @@ public class PostHogFake : PostHogInterface { sessionReplayActive = false } + /** + * Mirrors the real impl: stop-if-active then start. Sampling is not modelled — assertions + * that count `startSessionReplayCalls` after a `restartSessionReplay()` will see this + * call's increment too. Tests interested in restart specifically should assert on + * `restartSessionReplayCalls`. + */ override fun restartSessionReplay() { restartSessionReplayCalls++ - // mirror the real impl: stop then start (we don't model sampling here) if (sessionReplayActive) { sessionReplayActive = false stopSessionReplayCalls++ 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 ea1f48658..3511658b6 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 @@ -171,37 +171,26 @@ internal class PostHogLifecycleObserverIntegrationTest { 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) + // Stop/start cycle at 24h-10m keeps lastUpdatedSession recent so the next + // onStart routes through the 24h-rotation branch instead of the first-onStart branch. fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs - tenMinutesMs sut.onStop(ProcessLifecycleOwner.get()) - sut.onStart(ProcessLifecycleOwner.get()) // updates lastUpdatedSession + sut.onStart(ProcessLifecycleOwner.get()) - // 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) @@ -231,17 +220,14 @@ internal class PostHogLifecycleObserverIntegrationTest { 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() @@ -266,7 +252,6 @@ internal class PostHogLifecycleObserverIntegrationTest { val mainHandler = MainHandler() val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) val fake = createPostHogFake() - // replay was never active sut.install(fake) PostHogSessionManager.startSession() @@ -299,22 +284,18 @@ internal class PostHogLifecycleObserverIntegrationTest { 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) @@ -337,14 +318,12 @@ internal class PostHogLifecycleObserverIntegrationTest { val fake = createPostHogFake() sut.install(fake) - // RN sets its own session id + // RN owns its session id; the SDK must not rotate it even past the 24h cap. val sessionId = java.util.UUID.randomUUID() PostHogSessionManager.setSessionId(sessionId) - // 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 @@ -352,15 +331,11 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.onStop(ProcessLifecycleOwner.get()) sut.onStart(ProcessLifecycleOwner.get()) - // Session should NOT have been rotated since RN manages its own session assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) sut.uninstall() } - /** - * A simple fake date provider for testing time-dependent behavior. - */ private class FakeDateProviderForTest(initialTimeMs: Long = System.currentTimeMillis()) : PostHogDateProvider { var currentTimeMs: Long = initialTimeMs diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt new file mode 100644 index 000000000..813eaa36b --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogTouchActivityIntegrationTest.kt @@ -0,0 +1,43 @@ +package com.posthog.android.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.createPostHogFake +import org.junit.runner.RunWith +import kotlin.test.Test + +@RunWith(AndroidJUnit4::class) +internal class PostHogTouchActivityIntegrationTest { + private fun getSut(): PostHogTouchActivityIntegration { + val config = PostHogAndroidConfig(API_KEY) + return PostHogTouchActivityIntegration(config) + } + + @Test + fun `install and uninstall complete without throwing on a clean process`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.uninstall() + } + + @Test + fun `double install is idempotent`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.install(fake) + sut.uninstall() + } + + @Test + fun `uninstall after install can be re-installed`() { + val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) + sut.uninstall() + sut.install(fake) + sut.uninstall() + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ddb6d39e8..4c2f9ea41 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -900,14 +900,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 getSessionStartedAt ()J public final fun isReactNative ()Z public final fun isSessionActive ()Z public final fun isSessionExceedingMaxDuration (J)Z public final fun peekSessionId ()Ljava/util/UUID; public final fun setAppInBackground (Z)V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V - public final fun 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 65b596457..53398af5b 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -488,9 +488,7 @@ 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. + // Any capture counts as activity so an idle session rotates before its id is read. PostHogSessionManager.touchSession() val newDistinctId = distinctId ?: this.distinctId @@ -1449,12 +1447,11 @@ public class PostHog private constructor( return } sessionReplayHandler?.let { - // Stop any in-progress recording so the new session starts with cleared snapshot state. if (it.isActive()) { it.stop() } - // Re-evaluate sampling for the (already-current) session id; if the prior session - // was sampled out, this one might pass, and vice versa. + // Sampling decision is per-session-id; the prior session may have been sampled out + // and the new one may pass (or vice versa). if (!shouldRecordSession()) { return } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 31231c3af..8ed850687 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -217,13 +217,12 @@ public interface PostHogInterface : PostHogCoreInterface { public fun stopSessionReplay() /** - * Restarts session replay for the current session id. Stops any in-progress recording, - * re-runs the sampling decision for the (already-current) session, and resumes recording - * with fresh meta + full snapshot keyframes if sampling passes. + * Stops any in-progress recording, re-runs sampling for the current session id, and + * resumes recording with fresh meta + full-snapshot keyframes if sampling passes. * - * Unlike [startSessionReplay] (resumeCurrent = false), this does NOT rotate the session id — - * use it from places where the session has already changed (e.g. silent rotation from - * [PostHogSessionManager.getActiveSessionId]) so we don't rotate twice. + * Unlike [startSessionReplay] with `resumeCurrent = false`, this does not rotate the + * session id, so it's safe to call from a path that fired because the session already + * rotated (avoids a double-rotation). */ @PostHogInternal public fun restartSessionReplay() { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 2e853d734..7b87e8c3c 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -1,7 +1,6 @@ package com.posthog.internal import com.posthog.PostHogInternal -import com.posthog.PostHogVisibleForTesting import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.UUID @@ -38,9 +37,8 @@ public object PostHogSessionManager { @Volatile public var isReactNative: Boolean = false - // Default to background to mirror iOS: until the first lifecycle onStart fires we don't - // know we're foregrounded, and treating the process as bg means an expired session is - // cleared (returns null) rather than silently rotated before any UI is visible. + // Defaults to true so an expired session before the first onStart is cleared rather + // than silently rotated against a process that has no UI yet. @Volatile private var isAppInBackground: Boolean = true @@ -59,15 +57,13 @@ public object PostHogSessionManager { * 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)?) { + internal fun setOnSessionIdChangedListener(listener: (() -> Unit)?) { onSessionIdChangedListener = listener } public fun startSession() { var sessionChanged = false synchronized(sessionLock) { - // Re-check inside the lock — RN flag is set once at setup but checking - // here keeps state consistent with the lock's invariants. if (isReactNative || sessionId != sessionIdNone) return@synchronized val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() sessionId = TimeBasedEpochGenerator.generate() @@ -94,10 +90,9 @@ public object PostHogSessionManager { /** * Returns the timestamp (in milliseconds) when the current session was started, - * or 0 if no session is active. + * or 0 if no session is active. Test-only. */ - @PostHogVisibleForTesting - public fun getSessionStartedAt(): Long { + internal fun getSessionStartedAt(): Long { synchronized(sessionLock) { return sessionStartedAt } @@ -115,8 +110,7 @@ public object PostHogSessionManager { 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. + // Caller must hold sessionLock. private fun isIdle(now: Long): Boolean = sessionActivityTimestamp > 0L && (sessionActivityTimestamp + SESSION_INACTIVITY_DURATION) <= now @@ -146,7 +140,6 @@ public object PostHogSessionManager { tempSessionId = if (sessionId != sessionIdNone) sessionId else null } else { val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() - // Check inactivity first, then max-duration (mirror iOS order). if (isIdle(now) || isMaxExpired(now)) { sessionChanged = true tempSessionId = @@ -168,11 +161,8 @@ public object PostHogSessionManager { } /** - * 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. + * Marks user activity on the current session: rotates if idle past + * [SESSION_INACTIVITY_DURATION], otherwise refreshes the activity timestamp. * No-op when backgrounded so background events don't keep a dead session alive. */ public fun touchSession() { @@ -195,10 +185,8 @@ public object PostHogSessionManager { public fun setSessionId(sessionId: UUID) { var sessionChanged = false synchronized(sessionLock) { - // Only stamp on a real change; re-asserting the same id (e.g. RN syncing on every - // event) shouldn't reset the 24h clock or the inactivity timer. - // NOTE: this is an Android-specific divergence from iOS, where setSessionId always - // re-stamps. Keeping per Ioannis review on PR #494. + // Re-asserting the same id (e.g. RN syncing the active id on every event) must not + // reset the 24h max-duration or 30-min inactivity clocks — sessions would never expire. if (this.sessionId == sessionId) return@synchronized val now = dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() this.sessionId = sessionId @@ -220,10 +208,9 @@ public object PostHogSessionManager { } /** - * Read-only sibling of [getActiveSessionId]: returns the current session id without - * running the inactivity / max-duration checks, so callers reacting to a session-id - * change (e.g. PostHogReplayIntegration.onSessionIdChanged) can inspect the new id - * without risking a re-entrant rotation that would re-fire the listener. + * Read-only sibling of [getActiveSessionId]: skips the expiry checks so callers reacting + * to a session-id change can read the new id without risking a re-entrant rotation that + * would re-fire the listener. */ public fun peekSessionId(): UUID? { synchronized(sessionLock) { diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index ee514b840..d046caf3d 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -872,7 +872,7 @@ internal class PostHogTest { // 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. - // Background defaults to true (mirrors iOS); flip to fg so touchSession isn't a no-op. + // touchSession is a no-op when bg, so flip to fg. PostHogSessionManager.setAppInBackground(false) val realNow = System.currentTimeMillis() val fakeDate = TestDateProvider(realNow - (1000L * 60 * 31)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt index 01e7389b5..4c5ab46b6 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -132,11 +132,10 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.startSession() val originalSessionId = PostHogSessionManager.getActiveSessionId() - // Advance under the inactivity threshold (29 min) + // Touch at 29m resets the inactivity origin; 31m total is only 2m since touch. 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()) } @@ -150,7 +149,6 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.startSession() val originalSessionId = PostHogSessionManager.getActiveSessionId() - // Advance past inactivity threshold fakeDate.nowMs = baseTime + (1000L * 60 * 31) PostHogSessionManager.touchSession() @@ -170,8 +168,8 @@ internal class PostHogSessionManagerTest { 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. + // If touchSession refreshed the activity timestamp here, the getter wouldn't see + // the inactivity and would not clear. PostHogSessionManager.touchSession() assertNull(PostHogSessionManager.getActiveSessionId()) @@ -183,7 +181,6 @@ internal class PostHogSessionManagerTest { val fakeDate = FakeDateProvider(baseTime) PostHogSessionManager.setDateProvider(fakeDate) - // No startSession called PostHogSessionManager.touchSession() assertNull(PostHogSessionManager.getActiveSessionId()) @@ -215,7 +212,7 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.startSession() val originalSessionId = PostHogSessionManager.getActiveSessionId() - // Skip past inactivity threshold without calling touchSession + // No touchSession call — exercising the getter's own inactivity check. fakeDate.nowMs = baseTime + (1000L * 60 * 31) val rotatedSessionId = PostHogSessionManager.getActiveSessionId() @@ -249,6 +246,27 @@ internal class PostHogSessionManagerTest { assertEquals(baseTime, PostHogSessionManager.getSessionStartedAt()) } + @Test + internal fun `setSessionId is a no-op when the id is unchanged`() { + // Re-asserting the same id (e.g. RN syncing the current id on every event) must not + // reset the 24h max-duration or 30-min inactivity clocks. + val baseTime = 1_000_000_000_000L + val fakeDate = FakeDateProvider(baseTime) + PostHogSessionManager.setDateProvider(fakeDate) + + val id = UUID.randomUUID() + PostHogSessionManager.setSessionId(id) + val firstStartedAt = PostHogSessionManager.getSessionStartedAt() + assertEquals(baseTime, firstStartedAt) + + fakeDate.nowMs = baseTime + (1000L * 60 * 10) + PostHogSessionManager.setSessionId(id) + assertEquals(firstStartedAt, PostHogSessionManager.getSessionStartedAt()) + + PostHogSessionManager.setSessionId(UUID.randomUUID()) + assertEquals(fakeDate.nowMs, PostHogSessionManager.getSessionStartedAt()) + } + @Test internal fun `getActiveSessionId rotates foregrounded session after 24 hours`() { val baseTime = 1_000_000_000_000L @@ -259,7 +277,6 @@ internal class PostHogSessionManagerTest { 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() @@ -328,20 +345,18 @@ internal class PostHogSessionManagerTest { val fakeDate = FakeDateProvider(baseTime) PostHogSessionManager.setDateProvider(fakeDate) - // Wire the listener AFTER startSession so the explicit start-fire isn't counted; we - // want to measure only the silent rotation triggered by the getter. + // Wire the listener AFTER startSession so the explicit start-fire isn't counted. PostHogSessionManager.startSession() var callCount = 0 PostHogSessionManager.setOnSessionIdChangedListener { callCount++ } - PostHogSessionManager.getActiveSessionId() // no rotation yet + PostHogSessionManager.getActiveSessionId() assertEquals(0, callCount) fakeDate.nowMs = baseTime + (1000L * 60 * 60 * 24) + 1 - PostHogSessionManager.getActiveSessionId() // rotates + PostHogSessionManager.getActiveSessionId() assertEquals(1, callCount) - // Subsequent reads without further expiry don't re-fire PostHogSessionManager.getActiveSessionId() assertEquals(1, callCount) } @@ -371,7 +386,7 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.startSession() assertEquals(1, callCount) - // Re-asserting startSession on an already-active session is a no-op and must not refire. + // Idempotent re-assert on an already-active session must not refire. PostHogSessionManager.startSession() assertEquals(1, callCount) } @@ -385,7 +400,6 @@ internal class PostHogSessionManagerTest { PostHogSessionManager.endSession() assertEquals(1, callCount) - // No active session → no fire. PostHogSessionManager.endSession() assertEquals(1, callCount) } From 3a62ee0a4c762662c78db7f668a76e09a843f08d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 1 May 2026 14:55:37 -0400 Subject: [PATCH 18/25] test: cover restartSessionReplay sampling, onSessionIdChanged when inactive, and onStop touchSession - 6 PostHog.restartSessionReplay tests: closed SDK, disabled flag, active+sampling-pass, inactive+sampling-pass, sampling-fail, no-rotation guarantee - 3 PostHogReplayIntegration.onSessionIdChanged tests: fires restart when active, fires when inactive (sampling re-evaluation), no-op when session cleared - 1 PostHogLifecycleObserverIntegration test: onStop touches session before flipping bg so a fresh idle window starts from the moment of backgrounding Co-Authored-By: Claude Opus 4.7 (1M context) --- ...PostHogLifecycleObserverIntegrationTest.kt | 39 ++++++ .../replay/PostHogReplayIntegrationTest.kt | 99 +++++++++++++++ .../src/test/java/com/posthog/PostHogTest.kt | 116 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt 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 3511658b6..da1dee29f 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 @@ -302,6 +302,45 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.uninstall() } + @Test + fun `onStop touches the session before flipping the bg flag`() { + // Without the touch in onStop, the activity timestamp would still point at session + // start. After bg+30min the getter would clear the session even though the user just + // backgrounded the app a few minutes ago. + val baseTime = System.currentTimeMillis() + val fakeDateProvider = FakeDateProviderForTest(baseTime) + PostHogSessionManager.setDateProvider(fakeDateProvider) + val config = + PostHogAndroidConfig(API_KEY).apply { + dateProvider = fakeDateProvider + captureApplicationLifecycleEvents = false + } + val mainHandler = MainHandler() + val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle) + val fake = createPostHogFake() + sut.install(fake) + + PostHogSessionManager.startSession() + val sessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(sessionId) + + sut.onStart(ProcessLifecycleOwner.get()) + + // Sit foregrounded for 25 min with no activity (under the 30-min idle threshold). + val twentyFiveMinutesMs = 1000L * 60 * 25 + fakeDateProvider.currentTimeMs = baseTime + twentyFiveMinutesMs + + // Backgrounding — onStop must touch before flipping the bg flag. + sut.onStop(ProcessLifecycleOwner.get()) + + // 35 min total: 10 min since the onStop touch, well under the 30-min idle threshold. + // The getter under bg=true should preserve the session (clears only if idle is reached). + fakeDateProvider.currentTimeMs = baseTime + (1000L * 60 * 35) + assertEquals(sessionId, PostHogSessionManager.getActiveSessionId()) + + sut.uninstall() + } + @Test fun `onStart does not rotate session when React Native even if session exceeds 24 hours`() { PostHogSessionManager.isReactNative = true diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt new file mode 100644 index 000000000..cd6cd1a55 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -0,0 +1,99 @@ +package com.posthog.android.replay + +import android.content.Context +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.createPostHogFake +import com.posthog.android.internal.MainHandler +import com.posthog.internal.PostHogSessionManager +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) // PostHogReplayIntegration.isSupported() requires API >= O. +internal class PostHogReplayIntegrationTest { + private val context = mock() + + @BeforeTest + fun `set up`() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.setAppInBackground(false) + PostHogSessionManager.endSession() + } + + @AfterTest + fun `tear down`() { + PostHogSessionManager.isReactNative = false + PostHogSessionManager.endSession() + PostHogSessionManager.setAppInBackground(true) + } + + private fun getSut(): PostHogReplayIntegration { + val config = PostHogAndroidConfig(API_KEY) + val mainHandler = MainHandler() + return PostHogReplayIntegration(context, config, mainHandler) + } + + @Test + fun `onSessionIdChanged calls restartSessionReplay even when replay is inactive`() { + // Even if a previous session was sampled out, the new session may now pass — so the + // listener must drive into restartSessionReplay regardless of isSessionReplayActive. + val sut = getSut() + val fake = createPostHogFake() + fake.sessionReplayActive = false + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, fake.restartSessionReplayCalls) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged calls restartSessionReplay when replay is active`() { + val sut = getSut() + val fake = createPostHogFake() + fake.sessionReplayActive = true + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, fake.restartSessionReplayCalls) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged does not call restartSessionReplay when session is cleared`() { + // peekSessionId returns null → there's nothing to record on; cleared-session branch + // posts a stop() instead, never calls restartSessionReplay. + val sut = getSut() + val fake = createPostHogFake() + fake.sessionReplayActive = true + sut.install(fake) + try { + // No startSession — peekSessionId returns null. + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(0, fake.restartSessionReplayCalls) + } finally { + sut.uninstall() + } + } +} diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d046caf3d..d87b3d823 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2355,6 +2355,122 @@ internal class PostHogTest { assertTrue(integration.stopCalled) } + @Test + fun `restartSessionReplay does nothing when SDK is closed`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + val sut = getSut(url.toString(), integration = integration) + + sut.close() + integration.reset() + + sut.restartSessionReplay() + + assertFalse(integration.stopCalled) + assertFalse(integration.startCalled) + } + + @Test + fun `restartSessionReplay does nothing when replay flag is disabled`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + // No SESSION_REPLAY pref → isSessionReplayFlagEnabled() is false. + val sut = getSut(url.toString(), preloadFeatureFlags = false, integration = integration) + + integration.reset() + sut.restartSessionReplay() + + assertFalse(integration.stopCalled) + assertFalse(integration.startCalled) + + sut.close() + } + + @Test + fun `restartSessionReplay stops then starts when replay was active and sampling passes`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + + val myPrefs = PostHogMemoryPreferences() + myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) + + val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) + + integration.reset() + sut.restartSessionReplay() + + assertTrue(integration.stopCalled) + assertTrue(integration.startCalled) + // start(false) clears snapshot state so the new session emits fresh keyframes. + assertEquals(false, integration.resumeCurrent) + + sut.close() + } + + @Test + fun `restartSessionReplay starts even when replay was inactive and sampling passes`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(false) + + val myPrefs = PostHogMemoryPreferences() + myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) + + val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) + + integration.reset() + sut.restartSessionReplay() + + assertFalse(integration.stopCalled) + assertTrue(integration.startCalled) + assertEquals(false, integration.resumeCurrent) + + sut.close() + } + + @Test + fun `restartSessionReplay stops but does not start when sampling fails`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + + val myPrefs = PostHogMemoryPreferences() + myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "0")) + + val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) + + integration.reset() + sut.restartSessionReplay() + + assertTrue(integration.stopCalled) + assertFalse(integration.startCalled) + + sut.close() + } + + @Test + fun `restartSessionReplay does not rotate the session id`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(true) + + val myPrefs = PostHogMemoryPreferences() + myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) + + val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) + + val sessionIdBefore = sut.getSessionId() + sut.restartSessionReplay() + // Distinguishes restartSessionReplay from startSessionReplay(resumeCurrent = false), + // which would have rotated the session id. + assertEquals(sessionIdBefore, sut.getSessionId()) + + sut.close() + } + @Test fun `captureException captures exception with correct properties`() { val http = mockHttp() From 7733fffa0509f48839cdc2b9ed2cc99f5aa02ad0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 1 May 2026 14:59:50 -0400 Subject: [PATCH 19/25] fix: harden listener invocation and dateProvider visibility - guard the onSessionIdChanged listener with try/catch so a misbehaving replay handler can't propagate into the session-mutating call sites (capture, touchSession, etc.) and crash the host app - mark dateProvider as @Volatile; it's set on setup and read from any thread inside the session lock, but the setter doesn't take the lock so without volatile a reader could observe a stale null Co-Authored-By: Claude Opus 4.7 (1M context) --- posthog/src/main/java/com/posthog/PostHog.kt | 6 +++++- .../main/java/com/posthog/internal/PostHogSessionManager.kt | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 53398af5b..cb7c5f512 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -191,7 +191,11 @@ public class PostHog private constructor( queue.start() PostHogSessionManager.setOnSessionIdChangedListener { - sessionReplayHandler?.onSessionIdChanged() + try { + sessionReplayHandler?.onSessionIdChanged() + } catch (e: Throwable) { + config.logger.log("onSessionIdChanged listener failed: $e.") + } } startSession() diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 7b87e8c3c..594b4a824 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -16,6 +16,7 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone + @Volatile private var dateProvider: PostHogDateProvider? = null public fun setDateProvider(dateProvider: PostHogDateProvider) { From ca24cd21de2d6ffc5eb2140a8cb811e351c0313b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 1 May 2026 17:01:24 -0400 Subject: [PATCH 20/25] fix: drop duplicate listener fire and force keyframes after rotation - PostHog.startSession had a stale direct sessionReplayHandler.onSessionIdChanged() call left over from before the listener mechanism. Now that PostHogSessionManager.startSession fires the listener, the direct call was redundant and produced two listener invocations on bg->fg foregrounding, causing replay to do an unnecessary stop/restart cycle. - After start(false) clears snapshot states, post a redraw on each tracked decor view. Without this the new session's first user-driven onDraw can be tens of seconds away on a static UI, so type:3 incrementals shipped before the type:4/2 keyframes the player needs to render them. Verified: keyframes now ship <1s after rotation (was ~49s). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../posthog/android/replay/PostHogReplayIntegration.kt | 9 +++++++++ posthog/src/main/java/com/posthog/PostHog.kt | 2 -- 2 files changed, 9 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 0ab26c7c5..f4a023cb0 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 @@ -1605,6 +1605,15 @@ public class PostHogReplayIntegration( } isSessionReplayActive = true + + if (!resumeCurrent) { + // Without this, on a static UI the first user-driven onDraw can be tens of seconds + // away — and incremental events (type:3) would ship under the new session before + // the meta + full-snapshot keyframes (type:4 + type:2) needed to render them. + mainHandler.handler.post { + decorViews.keys.forEach { it.postInvalidate() } + } + } } private fun clearSnapshotStates() { diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index cb7c5f512..ae1f3391a 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1351,8 +1351,6 @@ public class PostHog private constructor( } PostHogSessionManager.startSession() - // Notify session replay handler about session change for event triggers - sessionReplayHandler?.onSessionIdChanged() } override fun endSession() { From 78e39fa6c63331c8d183e33b87d566ecd6ce9a51 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 15:10:15 -0400 Subject: [PATCH 21/25] refactor: inline sampling-aware restart in replay integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes restartSessionReplay() from PostHogInterface — it's the SDK's central public surface and even @PostHogInternal additions are highly visible. Move the sampling+restart logic into PostHogReplayIntegration.onSessionIdChanged itself, using config.remoteConfigHolder directly (it already accesses remoteConfigHolder for event triggers). - PostHogInterface.restartSessionReplay() removed (along with PostHog impl and Companion delegate). - PostHogReplayIntegration.onSessionIdChanged inlines: isSessionReplayFlagActive + makeSamplingDecision + start(false). - PostHogLifecycleObserverIntegration's 24h-foreground branch drops the explicit postHog.restartSessionReplay() call — the manager's listener fire from startSession now drives the integration directly. - PostHogTest restartSessionReplay tests removed. - PostHogReplayIntegrationTest rewritten to verify observable replay state (isActive) under mocked PostHogRemoteConfig with various flag/sampling combinations, instead of asserting on a fake-counter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PostHogLifecycleObserverIntegration.kt | 3 +- .../replay/PostHogReplayIntegration.kt | 21 +++- .../java/com/posthog/android/PostHogFake.kt | 17 --- .../replay/PostHogReplayIntegrationTest.kt | 99 +++++++++++---- posthog/api/posthog.api | 4 - posthog/src/main/java/com/posthog/PostHog.kt | 24 ---- .../main/java/com/posthog/PostHogInterface.kt | 12 -- .../src/test/java/com/posthog/PostHogTest.kt | 116 ------------------ 8 files changed, 96 insertions(+), 200 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 ade1900aa..2ef27da49 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 @@ -86,9 +86,10 @@ internal class PostHogLifecycleObserverIntegration( postHog?.startSessionReplay(resumeCurrent = true) } } else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { + // endSession + startSession both fire onSessionIdChangedListener on the manager; + // the replay integration handles stop + sampling-aware restart from there. postHog?.endSession() postHog?.startSession() - postHog?.restartSessionReplay() } this.lastUpdatedSession.set(currentTimeMillis) } 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 f4a023cb0..4f3b82038 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 @@ -1674,12 +1674,13 @@ public class PostHogReplayIntegration( * new session gets fresh meta + full wireframe events. */ override fun onSessionIdChanged() { - val postHog = this.postHog ?: return + if (this.postHog == null) return // Read-only: getActiveSessionId() can rotate the session and would re-fire this listener. val currentSessionId = PostHogSessionManager.peekSessionId()?.toString() - val triggers = config.remoteConfigHolder?.getEventTriggers() + val remoteConfig = config.remoteConfigHolder + val triggers = remoteConfig?.getEventTriggers() val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId } if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) { @@ -1701,11 +1702,21 @@ public class PostHogReplayIntegration( } // Run regardless of isSessionReplayActive: the prior session may have been sampled out - // and the new one may now pass. restartSessionReplay re-checks sampling without rotating - // the session id (which would double-rotate on top of the silent rotation that fired us). + // and the new one may now pass. Sampling is re-evaluated for the (already-current) + // session without rotating the id (the silent rotation that fired this already + // rotated; we'd double-rotate if we went through PostHog.startSessionReplay(false)). config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") mainHandler.handler.post { - postHog.restartSessionReplay() + if (remoteConfig?.isSessionReplayFlagActive() != true) { + if (isSessionReplayActive) stop() + return@post + } + if (remoteConfig.makeSamplingDecision(currentSessionId).not()) { + if (isSessionReplayActive) stop() + return@post + } + if (isSessionReplayActive) stop() + start(resumeCurrent = false) } } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index af108130f..948b7988a 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -17,7 +17,6 @@ public class PostHogFake : PostHogInterface { public var sessionReplayActive: Boolean = false public var startSessionReplayCalls: Int = 0 public var stopSessionReplayCalls: Int = 0 - public var restartSessionReplayCalls: Int = 0 override fun setup(config: T) { } @@ -209,22 +208,6 @@ public class PostHogFake : PostHogInterface { sessionReplayActive = false } - /** - * Mirrors the real impl: stop-if-active then start. Sampling is not modelled — assertions - * that count `startSessionReplayCalls` after a `restartSessionReplay()` will see this - * call's increment too. Tests interested in restart specifically should assert on - * `restartSessionReplayCalls`. - */ - override fun restartSessionReplay() { - restartSessionReplayCalls++ - if (sessionReplayActive) { - sessionReplayActive = false - stopSessionReplayCalls++ - } - sessionReplayActive = true - startSessionReplayCalls++ - } - override fun getSessionId(): UUID? { return null } diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index cd6cd1a55..024011d2c 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -7,15 +7,19 @@ import com.posthog.android.API_KEY import com.posthog.android.PostHogAndroidConfig import com.posthog.android.createPostHogFake import com.posthog.android.internal.MainHandler +import com.posthog.internal.PostHogRemoteConfig import com.posthog.internal.PostHogSessionManager import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [26]) // PostHogReplayIntegration.isSupported() requires API >= O. @@ -36,17 +40,30 @@ internal class PostHogReplayIntegrationTest { PostHogSessionManager.setAppInBackground(true) } - private fun getSut(): PostHogReplayIntegration { - val config = PostHogAndroidConfig(API_KEY) - val mainHandler = MainHandler() - return PostHogReplayIntegration(context, config, mainHandler) + private fun configWithSampling( + flagActive: Boolean, + samplingPasses: Boolean, + ): PostHogAndroidConfig { + val remoteConfig = + mock { + on { isSessionReplayFlagActive() } doReturn flagActive + on { makeSamplingDecision(any()) } doReturn samplingPasses + on { getEventTriggers() } doReturn emptySet() + } + return PostHogAndroidConfig(API_KEY).apply { + remoteConfigHolder = remoteConfig + } + } + + private fun getSut(config: PostHogAndroidConfig = PostHogAndroidConfig(API_KEY)): PostHogReplayIntegration { + return PostHogReplayIntegration(context, config, MainHandler()) } @Test - fun `onSessionIdChanged calls restartSessionReplay even when replay is inactive`() { - // Even if a previous session was sampled out, the new session may now pass — so the - // listener must drive into restartSessionReplay regardless of isSessionReplayActive. - val sut = getSut() + fun `onSessionIdChanged starts replay when previously inactive and sampling passes`() { + // The prior session may have been sampled out; rotation must re-evaluate sampling and + // start replay even though isSessionReplayActive was false. + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) val fake = createPostHogFake() fake.sessionReplayActive = false sut.install(fake) @@ -55,43 +72,83 @@ internal class PostHogReplayIntegrationTest { sut.onSessionIdChanged() shadowOf(Looper.getMainLooper()).idle() - assertEquals(1, fake.restartSessionReplayCalls) + assertTrue(sut.isActive()) } finally { sut.uninstall() } } @Test - fun `onSessionIdChanged calls restartSessionReplay when replay is active`() { - val sut = getSut() + fun `onSessionIdChanged stops then starts replay when active and sampling passes`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) val fake = createPostHogFake() - fake.sessionReplayActive = true sut.install(fake) try { PostHogSessionManager.startSession() + // Pre-activate replay so we can verify it's stopped+restarted, not just left running. + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + sut.onSessionIdChanged() shadowOf(Looper.getMainLooper()).idle() - assertEquals(1, fake.restartSessionReplayCalls) + assertTrue(sut.isActive()) } finally { sut.uninstall() } } @Test - fun `onSessionIdChanged does not call restartSessionReplay when session is cleared`() { - // peekSessionId returns null → there's nothing to record on; cleared-session branch - // posts a stop() instead, never calls restartSessionReplay. - val sut = getSut() + fun `onSessionIdChanged stops replay when sampling fails`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = false)) val fake = createPostHogFake() - fake.sessionReplayActive = true sut.install(fake) try { - // No startSession — peekSessionId returns null. + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops replay when session is cleared`() { + val sut = getSut(configWithSampling(flagActive = true, samplingPasses = true)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + // Clear the session, then fire onSessionIdChanged — peekSessionId returns null. + PostHogSessionManager.endSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged does not start replay when flag is disabled`() { + val sut = getSut(configWithSampling(flagActive = false, samplingPasses = true)) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() sut.onSessionIdChanged() shadowOf(Looper.getMainLooper()).idle() - assertEquals(0, fake.restartSessionReplayCalls) + assertFalse(sut.isActive()) } finally { sut.uninstall() } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 4c2f9ea41..f98c62d43 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -52,7 +52,6 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V - public fun restartSessionReplay ()V public fun screen (Ljava/lang/String;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -97,7 +96,6 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V public final fun resetSharedInstance ()V - public fun restartSessionReplay ()V public fun screen (Ljava/lang/String;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -334,7 +332,6 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public abstract fun resetPersonPropertiesForFlags (Z)V - public abstract fun restartSessionReplay ()V public abstract fun screen (Ljava/lang/String;Ljava/util/Map;)V public abstract fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public abstract fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V @@ -358,7 +355,6 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V - public static fun restartSessionReplay (Lcom/posthog/PostHogInterface;)V public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun setGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ZILjava/lang/Object;)V public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index ae1f3391a..20ff03c82 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1441,26 +1441,6 @@ public class PostHog private constructor( } } - override fun restartSessionReplay() { - if (!isEnabled()) { - return - } - if (!isSessionReplayFlagEnabled()) { - return - } - sessionReplayHandler?.let { - if (it.isActive()) { - it.stop() - } - // Sampling decision is per-session-id; the prior session may have been sampled out - // and the new one may pass (or vice versa). - if (!shouldRecordSession()) { - return - } - it.start(false) - } - } - override fun stopSessionReplay() { if (!isEnabled()) { return @@ -1747,10 +1727,6 @@ public class PostHog private constructor( shared.stopSessionReplay() } - override fun restartSessionReplay() { - shared.restartSessionReplay() - } - override fun getSessionId(): UUID? { return shared.getSessionId() } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 8ed850687..2a6948581 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -216,18 +216,6 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun stopSessionReplay() - /** - * Stops any in-progress recording, re-runs sampling for the current session id, and - * resumes recording with fresh meta + full-snapshot keyframes if sampling passes. - * - * Unlike [startSessionReplay] with `resumeCurrent = false`, this does not rotate the - * session id, so it's safe to call from a path that fired because the session already - * rotated (avoids a double-rotation). - */ - @PostHogInternal - public fun restartSessionReplay() { - } - /** * Returns the session Id if a session is active */ diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d87b3d823..d046caf3d 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2355,122 +2355,6 @@ internal class PostHogTest { assertTrue(integration.stopCalled) } - @Test - fun `restartSessionReplay does nothing when SDK is closed`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(true) - val sut = getSut(url.toString(), integration = integration) - - sut.close() - integration.reset() - - sut.restartSessionReplay() - - assertFalse(integration.stopCalled) - assertFalse(integration.startCalled) - } - - @Test - fun `restartSessionReplay does nothing when replay flag is disabled`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(true) - // No SESSION_REPLAY pref → isSessionReplayFlagEnabled() is false. - val sut = getSut(url.toString(), preloadFeatureFlags = false, integration = integration) - - integration.reset() - sut.restartSessionReplay() - - assertFalse(integration.stopCalled) - assertFalse(integration.startCalled) - - sut.close() - } - - @Test - fun `restartSessionReplay stops then starts when replay was active and sampling passes`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(true) - - val myPrefs = PostHogMemoryPreferences() - myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) - - val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) - - integration.reset() - sut.restartSessionReplay() - - assertTrue(integration.stopCalled) - assertTrue(integration.startCalled) - // start(false) clears snapshot state so the new session emits fresh keyframes. - assertEquals(false, integration.resumeCurrent) - - sut.close() - } - - @Test - fun `restartSessionReplay starts even when replay was inactive and sampling passes`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(false) - - val myPrefs = PostHogMemoryPreferences() - myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) - - val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) - - integration.reset() - sut.restartSessionReplay() - - assertFalse(integration.stopCalled) - assertTrue(integration.startCalled) - assertEquals(false, integration.resumeCurrent) - - sut.close() - } - - @Test - fun `restartSessionReplay stops but does not start when sampling fails`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(true) - - val myPrefs = PostHogMemoryPreferences() - myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "0")) - - val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) - - integration.reset() - sut.restartSessionReplay() - - assertTrue(integration.stopCalled) - assertFalse(integration.startCalled) - - sut.close() - } - - @Test - fun `restartSessionReplay does not rotate the session id`() { - val http = mockHttp() - val url = http.url("/") - val integration = PostHogSessionReplayHandlerFake(true) - - val myPrefs = PostHogMemoryPreferences() - myPrefs.setValue(SESSION_REPLAY, mapOf("sampleRate" to "1")) - - val sut = getSut(url.toString(), cachePreferences = myPrefs, preloadFeatureFlags = false, integration = integration) - - val sessionIdBefore = sut.getSessionId() - sut.restartSessionReplay() - // Distinguishes restartSessionReplay from startSessionReplay(resumeCurrent = false), - // which would have rotated the session id. - assertEquals(sessionIdBefore, sut.getSessionId()) - - sut.close() - } - @Test fun `captureException captures exception with correct properties`() { val http = mockHttp() From 1c7f81c906f267a9f75034479f3d141a7b7101ab Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 15:47:39 -0400 Subject: [PATCH 22/25] refactor: stop shadowing session state in lifecycle observer PostHogLifecycleObserverIntegration was tracking its own copy of "session activity" via a lastUpdatedSession AtomicLong updated only on lifecycle transitions, plus a replayActiveBeforeRotation flag for the bg-then-fg rebound. Both shadow state the manager already owns authoritatively, and both can drift the moment the user touches the screen or fires an event between lifecycle ticks. With the manager now firing onSessionIdChangedListener on every state change and the replay integration handling sampling-aware restart inline, those fields are redundant: - onStart simply touches the session (manager rotates if idle), then calls startSession (no-op if alive, creates if cleared during bg). Both fire the listener; the integration handles replay restart with sampling. - onStop's 24h-expired branch ends the session and stops replay synchronously (process may suspend before the listener's main-thread post runs); the else branch just schedules the bg-end timer. Two affected tests rewritten to assert the observable contract: - "onStop ends session and stops replay synchronously when 24h expired" - "onStart creates a fresh session after a 24h-expired onStop" Replay restart on rotation is verified via the listener path in PostHogReplayIntegrationTest with mocked sampling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PostHogLifecycleObserverIntegration.kt | 51 ++++--------------- ...PostHogLifecycleObserverIntegrationTest.kt | 25 ++++----- 2 files changed, 24 insertions(+), 52 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 2ef27da49..5a8045c5a 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogLifecycleObserverIntegration.kt @@ -11,8 +11,6 @@ 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 /** * Captures app opened and backgrounded events @@ -29,9 +27,9 @@ internal class PostHogLifecycleObserverIntegration( private val timerLock = Any() private var timer = Timer(true) private var timerTask: TimerTask? = null - private val lastUpdatedSession = AtomicLong(0L) - private val replayActiveBeforeRotation = AtomicBoolean(false) - private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes + + // Bg timeout for forcing endSession when no events fire to drive the manager's getter check. + private val bgEndSessionDelayMs = (1000 * 60 * 30).toLong() // 30 minutes private var postHog: PostHogInterface? = null @@ -47,11 +45,13 @@ internal class PostHogLifecycleObserverIntegration( } override fun onStart(owner: LifecycleOwner) { + cancelTask() PostHogSessionManager.setAppInBackground(false) - // Foregrounding counts as activity so an idle session rotates here, not on the - // first capture after foregrounding. + // touchSession rotates an idle session; startSession creates a fresh one if the + // session was cleared during bg. Both fire the manager's session-id-changed listener, + // which drives the sampling-aware replay restart in the replay integration. PostHogSessionManager.touchSession() - startSession() + postHog?.startSession() if (config.captureApplicationLifecycleEvents) { val props = mutableMapOf() @@ -70,30 +70,6 @@ internal class PostHogLifecycleObserverIntegration( } } - private fun startSession() { - cancelTask() - - val currentTimeMillis = config.dateProvider.currentTimeMillis() - val lastUpdatedSession = lastUpdatedSession.get() - - if (lastUpdatedSession == 0L || - (lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis - ) { - postHog?.startSession() - // Resume replay if it was active when the previous onStop tore it down for a - // 24h rotation; otherwise the new session would have no recording. - if (replayActiveBeforeRotation.compareAndSet(true, false)) { - postHog?.startSessionReplay(resumeCurrent = true) - } - } else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) { - // endSession + startSession both fire onSessionIdChangedListener on the manager; - // the replay integration handles stop + sampling-aware restart from there. - postHog?.endSession() - postHog?.startSession() - } - this.lastUpdatedSession.set(currentTimeMillis) - } - private fun cancelTask() { synchronized(timerLock) { timerTask?.cancel() @@ -113,7 +89,7 @@ internal class PostHogLifecycleObserverIntegration( postHog?.endSession() } } - timer.schedule(timerTask, sessionMaxInterval) + timer.schedule(timerTask, bgEndSessionDelayMs) } } @@ -135,16 +111,11 @@ internal class PostHogLifecycleObserverIntegration( if (wasExpired) { cancelTask() - val wasReplayActive = postHog?.isSessionReplayActive() == true + // Force the rotation now and stop replay synchronously — process may suspend + // before the listener's main-thread post can run. postHog?.endSession() - // Synchronous stop guarantees replay is torn down before the process suspends; - // the listener-driven path posts to main and may not run in time. postHog?.stopSessionReplay() - replayActiveBeforeRotation.set(wasReplayActive) - // Zeroing forces the next onStart into the "create a fresh session" branch. - lastUpdatedSession.set(0L) } else { - lastUpdatedSession.set(currentTimeMillis) scheduleEndSession() } } 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 da1dee29f..bf44f4593 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 @@ -23,6 +23,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) internal class PostHogLifecycleObserverIntegrationTest { @@ -199,7 +200,10 @@ internal class PostHogLifecycleObserverIntegrationTest { } @Test - fun `onStart restarts session replay after 24h rotation in background when replay was active`() { + fun `onStop ends session and stops replay synchronously when 24h expired`() { + // Replay restart on the next onStart is now driven by the manager's listener + // (covered in PostHogReplayIntegrationTest); here we verify only the synchronous + // teardown that has to happen before the process suspends. val baseTime = System.currentTimeMillis() val fakeDateProvider = FakeDateProviderForTest(baseTime) PostHogSessionManager.setDateProvider(fakeDateProvider) @@ -227,20 +231,14 @@ internal class PostHogLifecycleObserverIntegrationTest { assertEquals(1, fake.stopSessionReplayCalls) assertEquals(false, fake.sessionReplayActive) - - sut.onStart(ProcessLifecycleOwner.get()) - - val secondSessionId = PostHogSessionManager.getActiveSessionId() - assertNotNull(secondSessionId) - assertNotEquals(firstSessionId, secondSessionId) - assertEquals(1, fake.startSessionReplayCalls) - assertEquals(true, fake.sessionReplayActive) + // Session was ended; the next onStart will create a fresh one. + assertNull(PostHogSessionManager.getActiveSessionId()) sut.uninstall() } @Test - fun `onStart does not restart session replay after 24h rotation when replay was inactive`() { + fun `onStart creates a fresh session after a 24h-expired onStop`() { val baseTime = System.currentTimeMillis() val fakeDateProvider = FakeDateProviderForTest(baseTime) PostHogSessionManager.setDateProvider(fakeDateProvider) @@ -255,6 +253,8 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.install(fake) PostHogSessionManager.startSession() + val firstSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(firstSessionId) sut.onStart(ProcessLifecycleOwner.get()) val twentyFourHoursMs = 1000L * 60 * 60 * 24 @@ -263,8 +263,9 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.onStop(ProcessLifecycleOwner.get()) sut.onStart(ProcessLifecycleOwner.get()) - assertEquals(0, fake.startSessionReplayCalls) - assertEquals(false, fake.sessionReplayActive) + val secondSessionId = PostHogSessionManager.getActiveSessionId() + assertNotNull(secondSessionId) + assertNotEquals(firstSessionId, secondSessionId) sut.uninstall() } From 15548f75ba1709a8de20963fa7d4f83df9cda6fc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 17:36:04 -0400 Subject: [PATCH 23/25] fix: gate replay rotation auto-start on config.sessionReplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dustinbyrne flagged that onSessionIdChanged would auto-start replay on session rotation whenever the remote flag and sampling both pass — even if the customer disabled replay at config level via config.sessionReplay = false. That diverges from iOS / Android-main, where config.sessionReplay is the master switch for any auto-start. Add the config.sessionReplay check to the rotation post block, alongside the existing remote-flag and sampling gates. Customer-driven starts via PostHog.startSessionReplay() and trigger-matched starts are unaffected. Two new PostHogReplayIntegrationTest cases: - does not auto-start when config.sessionReplay is false (flag + sampling pass) - stops an already-active replay on rotation under config.sessionReplay = false Also fixed a stale comment in PostHogLifecycleObserverIntegrationTest that referenced lastUpdatedSession (removed in the simplification refactor). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../replay/PostHogReplayIntegration.kt | 9 ++- ...PostHogLifecycleObserverIntegrationTest.kt | 10 +--- .../replay/PostHogReplayIntegrationTest.kt | 55 +++++++++++++++++++ 3 files changed, 66 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 4f3b82038..c55390063 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -1704,9 +1704,16 @@ public class PostHogReplayIntegration( // Run regardless of isSessionReplayActive: the prior session may have been sampled out // and the new one may now pass. Sampling is re-evaluated for the (already-current) // session without rotating the id (the silent rotation that fired this already - // rotated; we'd double-rotate if we went through PostHog.startSessionReplay(false)). + // rotated; going through PostHog.startSessionReplay(false) would double-rotate). config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") mainHandler.handler.post { + // config.sessionReplay is the customer-facing master switch: a config-level disable + // must not be overridden by remote flag + sampling. Manual PostHog.startSessionReplay + // calls and trigger-matched starts go through different code paths and are unaffected. + if (!config.sessionReplay) { + if (isSessionReplayActive) stop() + return@post + } if (remoteConfig?.isSessionReplayFlagActive() != true) { if (isSessionReplayActive) stop() return@post 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 bf44f4593..f1db8f844 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogLifecycleObserverIntegrationTest.kt @@ -179,15 +179,11 @@ internal class PostHogLifecycleObserverIntegrationTest { sut.onStart(ProcessLifecycleOwner.get()) val twentyFourHoursMs = 1000L * 60 * 60 * 24 - val tenMinutesMs = 1000L * 60 * 10 val oneMinuteMs = 1000L * 60 - // Stop/start cycle at 24h-10m keeps lastUpdatedSession recent so the next - // onStart routes through the 24h-rotation branch instead of the first-onStart branch. - fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs - tenMinutesMs - sut.onStop(ProcessLifecycleOwner.get()) - sut.onStart(ProcessLifecycleOwner.get()) - + // Advance past the 24h cap and cycle through bg→fg. onStop's wasExpired branch + // ends the session; the next onStart calls startSession on the cleared manager, + // which mints a fresh id. fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs sut.onStop(ProcessLifecycleOwner.get()) sut.onStart(ProcessLifecycleOwner.get()) diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 024011d2c..fd532daca 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -43,6 +43,7 @@ internal class PostHogReplayIntegrationTest { private fun configWithSampling( flagActive: Boolean, samplingPasses: Boolean, + sessionReplay: Boolean = true, ): PostHogAndroidConfig { val remoteConfig = mock { @@ -52,6 +53,7 @@ internal class PostHogReplayIntegrationTest { } return PostHogAndroidConfig(API_KEY).apply { remoteConfigHolder = remoteConfig + this.sessionReplay = sessionReplay } } @@ -153,4 +155,57 @@ internal class PostHogReplayIntegrationTest { sut.uninstall() } } + + @Test + fun `onSessionIdChanged does not auto-start replay when config sessionReplay is false`() { + // config.sessionReplay is the master switch — even if remote flag and sampling both + // pass, we must not auto-start replay if the customer disabled it at config level. + val sut = + getSut( + configWithSampling( + flagActive = true, + samplingPasses = true, + sessionReplay = false, + ), + ) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `onSessionIdChanged stops active replay on rotation when config sessionReplay is false`() { + // Defensive: if replay was somehow started (e.g. trigger-matched, or pre-config-flip), + // a rotation under config.sessionReplay = false should stop it rather than restart. + val sut = + getSut( + configWithSampling( + flagActive = true, + samplingPasses = true, + sessionReplay = false, + ), + ) + val fake = createPostHogFake() + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(sut.isActive()) + } finally { + sut.uninstall() + } + } } From ceea09bae7f7c27ac60153be53be1c2c73ed2713 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 5 May 2026 09:22:18 -0400 Subject: [PATCH 24/25] fix: scope session activity tracking and RN-bypass max-duration Two changes from ioannisj review: 1. capture() no longer calls touchSession(). Replay snapshots happen without user engagement, and capture() can carry a caller-provided $session_id that targets a different session entirely. Activity tracking is now strictly gestures (PostHogTouchActivityIntegration) + lifecycle bg/fg transitions (PostHogLifecycleObserverIntegration). Drops the now-stale `capture rotates idle session via touchSession before reading session id` test. 2. isSessionExceedingMaxDuration short-circuits to false when isReactNative. JS owns the session lifecycle on RN; the native side must not drive rotation decisions on top of it. Adds matching test. Co-Authored-By: Claude Opus 4.7 (1M context) --- posthog/src/main/java/com/posthog/PostHog.kt | 2 -- .../posthog/internal/PostHogSessionManager.kt | 3 ++ .../src/test/java/com/posthog/PostHogTest.kt | 33 ------------------- .../internal/PostHogSessionManagerTest.kt | 12 +++++++ 4 files changed, 15 insertions(+), 35 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 20ff03c82..1d334379e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -492,8 +492,6 @@ public class PostHog private constructor( config?.logger?.log("PostHog is in OptOut state.") return } - // Any capture counts as activity so an idle session rotates before its id is read. - 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 594b4a824..8c3dc471b 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -101,9 +101,12 @@ public object PostHogSessionManager { /** * Returns true if the current session has been active for longer than 24 hours. + * Always false for React Native — JS owns the session lifecycle and the native side + * must not drive rotation decisions on top of it. */ public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean { synchronized(sessionLock) { + if (isReactNative) return false return isMaxExpired(currentTimeMillis) } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d046caf3d..1370283f3 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -864,39 +864,6 @@ 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. - // touchSession is a no-op when bg, so flip to fg. - PostHogSessionManager.setAppInBackground(false) - 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 4c5ab46b6..3a1c75256 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSessionManagerTest.kt @@ -123,6 +123,18 @@ internal class PostHogSessionManagerTest { assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(System.currentTimeMillis())) } + @Test + internal fun `isSessionExceedingMaxDuration returns false for React Native even past 24h`() { + // RN owns the session lifecycle from JS; the native side must not drive rotation + // decisions on top of it. + PostHogSessionManager.isReactNative = true + PostHogSessionManager.setSessionId(UUID.randomUUID()) + val startedAt = PostHogSessionManager.getSessionStartedAt() + + val twentyFiveHours = startedAt + (1000L * 60 * 60 * 25) + assertFalse(PostHogSessionManager.isSessionExceedingMaxDuration(twentyFiveHours)) + } + @Test internal fun `touchSession refreshes activity timestamp without rotating`() { val baseTime = 1_000_000_000_000L From 55cf017e962ed2991af4e12ac4b2a0ac3ad91eb8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 5 May 2026 12:20:54 -0400 Subject: [PATCH 25/25] fix: default isAppInBackground to false; Android opts into true at SDK init Per ioannisj review: with the default at true, any non-Android JVM consumer of `posthog` core (which has no lifecycle observer to flip the flag) would forever clear expired sessions instead of rotating them. No real consumer hits this today (`posthog-server` uses `PostHogStateless`, not the session manager), but `PostHog.with(config)` is a public static factory and a future JVM consumer could trip on it. - PostHogSessionManager.isAppInBackground default flipped to false (rotates on expiry, sensible for headless usage). - PostHogAndroid.setAndroidConfig explicitly calls setAppInBackground(true) at init to preserve the iOS-parity "no UI yet at startup" semantic; the lifecycle observer's first onStart still flips it to false as before. Net effect: zero behavioural change for Android (the flag is still true at SDK init and false after the first onStart). Non-Android JVM consumers now get sensible rotate-on-expiry behaviour by default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/com/posthog/android/PostHogAndroid.kt | 3 +++ .../java/com/posthog/internal/PostHogSessionManager.kt | 8 +++++--- 2 files changed, 8 insertions(+), 3 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 001ba349b..2b511f038 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -123,6 +123,9 @@ public class PostHogAndroid private constructor() { } PostHogSessionManager.isReactNative = config.sdkName == "posthog-react-native" + // Mark the process as backgrounded until the first onStart fires; an expired + // session before any UI exists is cleared rather than silently rotated. + PostHogSessionManager.setAppInBackground(true) val releaseIdentifierFallback = "$packageName@$versionName+$buildNumber" val metaPropertiesApplier = PostHogMetaPropertiesApplier() diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 8c3dc471b..91f6e3fb6 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -38,10 +38,12 @@ public object PostHogSessionManager { @Volatile public var isReactNative: Boolean = false - // Defaults to true so an expired session before the first onStart is cleared rather - // than silently rotated against a process that has no UI yet. + // Default false so non-Android JVM consumers (no lifecycle to flip this) rotate + // expired sessions instead of clearing them. PostHogAndroid explicitly sets this + // to true at SDK init to preserve the "no UI yet at startup" semantic; the lifecycle + // observer flips it back to false on the first onStart. @Volatile - private var isAppInBackground: Boolean = true + private var isAppInBackground: Boolean = false @Volatile private var onSessionIdChangedListener: (() -> Unit)? = null