From 9b4025aaf9e0881371a456d326b0ad604a849f8c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 28 Jul 2026 20:05:15 +0200 Subject: [PATCH 1/4] fix: make integration installation atomic --- .changeset/calm-guards-install.md | 6 ++ ...HogActivityLifecycleCallbackIntegration.kt | 9 ++- .../internal/PostHogAppInstallIntegration.kt | 13 ++--- .../PostHogLifecycleObserverIntegration.kt | 9 ++- .../PostHogTouchActivityIntegration.kt | 9 ++- .../replay/PostHogReplayIntegration.kt | 9 ++- .../internal/PostHogLogCatIntegration.kt | 15 +++-- ...ctivityLifecycleCallbackIntegrationTest.kt | 26 +++++++++ ...tHogErrorTrackingAutoCaptureIntegration.kt | 13 +++-- .../PostHogSendCachedEventsIntegration.kt | 9 ++- .../PostHogSendCachedEventsIntegrationTest.kt | 55 +++++++++++++++++++ 11 files changed, 127 insertions(+), 46 deletions(-) create mode 100644 .changeset/calm-guards-install.md diff --git a/.changeset/calm-guards-install.md b/.changeset/calm-guards-install.md new file mode 100644 index 000000000..e551ffde0 --- /dev/null +++ b/.changeset/calm-guards-install.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Prevent duplicate integration installation during concurrent SDK setup. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt index ec085cc54..552e140f6 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt @@ -7,6 +7,7 @@ import android.os.Bundle import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig +import java.util.concurrent.atomic.AtomicBoolean /** * Captures deep link and screen view events @@ -20,8 +21,7 @@ internal class PostHogActivityLifecycleCallbackIntegration( private var postHog: PostHogInterface? = null private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun onActivityCreated( @@ -84,10 +84,9 @@ internal class PostHogActivityLifecycleCallbackIntegration( } override fun install(postHog: PostHogInterface) { - if (integrationInstalled) { + if (!integrationInstalled.compareAndSet(false, true)) { return } - integrationInstalled = true this.postHog = postHog application.registerActivityLifecycleCallbacks(this) @@ -95,7 +94,7 @@ internal class PostHogActivityLifecycleCallbackIntegration( override fun uninstall() { this.postHog = null - integrationInstalled = false + integrationInstalled.set(false) application.unregisterActivityLifecycleCallbacks(this) } } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt index b89dad3db..cf7d12d45 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt @@ -6,6 +6,7 @@ import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig import com.posthog.internal.PostHogPreferences.Companion.BUILD import com.posthog.internal.PostHogPreferences.Companion.VERSION +import java.util.concurrent.atomic.AtomicBoolean /** * Captures app installed and updated events @@ -17,14 +18,10 @@ internal class PostHogAppInstallIntegration( private val config: PostHogAndroidConfig, ) : PostHogIntegration { private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun install(postHog: PostHogInterface) { - if (integrationInstalled) { - return - } // While the store is unreadable (Direct Boot) VERSION/BUILD read as absent, which would // fire a spurious "Application Installed" for an existing install and overwrite the // persisted previous build on unlock. Stay uninstalled so the correct event can still be @@ -32,7 +29,9 @@ internal class PostHogAppInstallIntegration( if (config.cachePreferences?.isAvailable() == false) { return } - integrationInstalled = true + if (!integrationInstalled.compareAndSet(false, true)) { + return + } getPackageInfo(context, config)?.let { packageInfo -> config.cachePreferences?.let { preferences -> @@ -75,6 +74,6 @@ internal class PostHogAppInstallIntegration( } override fun uninstall() { - integrationInstalled = false + integrationInstalled.set(false) } } 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 5a8045c5a..26f74c539 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 /** * Captures app opened and backgrounded events @@ -40,8 +41,7 @@ internal class PostHogLifecycleObserverIntegration( @Volatile private var fromBackground = false - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun onStart(owner: LifecycleOwner) { @@ -125,10 +125,9 @@ internal class PostHogLifecycleObserverIntegration( } override fun install(postHog: PostHogInterface) { - if (integrationInstalled) { + if (!integrationInstalled.compareAndSet(false, true)) { return } - integrationInstalled = true try { this.postHog = postHog @@ -150,7 +149,7 @@ internal class PostHogLifecycleObserverIntegration( override fun uninstall() { try { - integrationInstalled = false + integrationInstalled.set(false) this.postHog = null if (isMainThread(mainHandler)) { remove() 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 459b9985b..61625c8e7 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 @@ -10,6 +10,7 @@ import curtains.OnRootViewsChangedListener import curtains.TouchEventInterceptor import curtains.phoneWindow import curtains.touchEventInterceptors +import java.util.concurrent.atomic.AtomicBoolean /** * Marks user touches as session activity by calling [PostHogSessionManager.touchSession] @@ -23,8 +24,7 @@ internal class PostHogTouchActivityIntegration( private val config: PostHogAndroidConfig, ) : PostHogIntegration { private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } private val touchInterceptor = @@ -54,10 +54,9 @@ internal class PostHogTouchActivityIntegration( } override fun install(postHog: PostHogInterface) { - if (integrationInstalled || !isSupported()) { + if (!isSupported() || !integrationInstalled.compareAndSet(false, true)) { return } - integrationInstalled = true try { Curtains.rootViews.forEach { view -> view.phoneWindow?.let { window -> @@ -83,7 +82,7 @@ internal class PostHogTouchActivityIntegration( } catch (e: Throwable) { config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") } finally { - integrationInstalled = false + integrationInstalled.set(false) } } 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 ef35d54ba..25fb83a1d 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 @@ -101,6 +101,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean public class PostHogReplayIntegration( private val context: Context, @@ -525,10 +526,9 @@ public class PostHogReplayIntegration( } override fun install(postHog: PostHogInterface) { - if (integrationInstalled || !isSupported()) { + if (!isSupported() || !integrationInstalled.compareAndSet(false, true)) { return } - integrationInstalled = true this.postHog = postHog // Wire up as buffer delegate for the replay queue @@ -560,7 +560,7 @@ public class PostHogReplayIntegration( override fun uninstall() { try { - integrationInstalled = false + integrationInstalled.set(false) this.postHog = null // Clear buffer delegate @@ -2335,7 +2335,6 @@ public class PostHogReplayIntegration( const val ANDROID_COMPOSE_VIEW_CLASS_NAME: String = "androidx.compose.ui.platform.AndroidComposeView" const val ANDROID_COMPOSE_VIEW: String = "AndroidComposeView" - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt index 3895da4bf..0ec0a90c4 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt @@ -9,6 +9,7 @@ import com.posthog.internal.replay.RRPluginEvent import com.posthog.internal.replay.capture import java.text.SimpleDateFormat import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig) : PostHogIntegration { @Volatile @@ -22,20 +23,18 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig private var postHog: PostHogInterface? = null private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun install(postHog: PostHogInterface) { this.postHog = postHog - if (integrationInstalled) { - return - } val captureLogcat = config.remoteConfigHolder?.isConsoleLogRecordingEnabled() ?: true if (!config.sessionReplayConfig.captureLogcat || !captureLogcat) { return } - integrationInstalled = true + if (!integrationInstalled.compareAndSet(false, true)) { + return + } val cmd = mutableListOf("logcat", "-v", "threadtime", "*:E") val sdf = SimpleDateFormat("MM-dd HH:mm:ss.mmm", Locale.ROOT) cmd.add("-T") @@ -107,10 +106,10 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig } @PostHogVisibleForTesting - internal fun isInstalled(): Boolean = integrationInstalled + internal fun isInstalled(): Boolean = integrationInstalled.get() override fun uninstall() { - integrationInstalled = false + integrationInstalled.set(false) logcatInProgress = false logcatThread?.interruptSafely() } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index a93875214..42d52c321 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -13,7 +13,9 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify +import java.util.concurrent.CountDownLatch import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -40,6 +42,30 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { PostHog.resetSharedInstance() } + @Test + fun `concurrent installs only register one lifecycle callback`() { + val threadCount = 32 + val integrations = List(threadCount) { getSut() } + val fake = createPostHogFake() + val ready = CountDownLatch(threadCount) + val start = CountDownLatch(1) + val threads = + integrations.map { integration -> + Thread { + ready.countDown() + start.await() + integration.install(fake) + }.apply { start() } + } + + ready.await() + start.countDown() + threads.forEach { it.join() } + + verify(application, times(1)).registerActivityLifecycleCallbacks(any()) + integrations.forEach { it.uninstall() } + } + @Test fun `install registers the lifecycle callback`() { val sut = getSut() diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 8e59687d4..f41a7cdf5 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -5,6 +5,7 @@ import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter +import java.util.concurrent.atomic.AtomicBoolean public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Thread.UncaughtExceptionHandler { private val config: PostHogConfig @@ -23,14 +24,13 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun install(postHog: PostHogInterface) { this.postHog = postHog - if (integrationInstalled) { + if (integrationInstalled.get()) { return } @@ -54,17 +54,18 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } private fun installHandler() { + if (!integrationInstalled.compareAndSet(false, true)) { + return + } adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) - integrationInstalled = true config.logger.log("Exception autocapture is enabled.") } override fun uninstall() { - if (!integrationInstalled) { + if (!integrationInstalled.compareAndSet(true, false)) { return } adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) - integrationInstalled = false config.logger.log("Exception autocapture is disabled.") } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt index 0bed41d58..478ef93f3 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt @@ -7,6 +7,7 @@ import com.posthog.PostHogInterface import java.io.File import java.io.IOException import java.util.concurrent.ExecutorService +import java.util.concurrent.atomic.AtomicBoolean /** * The integration that sends all the cached legacy events, triggered once the SDK is setup @@ -20,15 +21,13 @@ internal class PostHogSendCachedEventsIntegration( private val executor: ExecutorService, ) : PostHogIntegration { private companion object { - @Volatile - private var integrationInstalled = false + private val integrationInstalled = AtomicBoolean(false) } override fun install(postHog: PostHogInterface) { - if (integrationInstalled) { + if (!integrationInstalled.compareAndSet(false, true)) { return } - integrationInstalled = true executor.executeSafely { if (config.networkStatus?.isConnected() == false) { @@ -139,6 +138,6 @@ internal class PostHogSendCachedEventsIntegration( } override fun uninstall() { - integrationInstalled = false + integrationInstalled.set(false) } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt index 11867efab..ba7047241 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt @@ -2,15 +2,21 @@ package com.posthog.internal import com.posthog.API_KEY import com.posthog.PostHogConfig +import com.posthog.PostHogInterface import com.posthog.shutdownAndAwaitTermination import com.posthog.vendor.uuid.TimeBasedEpochGenerator import org.junit.Rule import org.junit.rules.TemporaryFolder import org.mockito.Mockito.mock import java.io.File +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse internal class PostHogSendCachedEventsIntegrationTest { @@ -55,6 +61,55 @@ internal class PostHogSendCachedEventsIntegrationTest { return storagePrefix } + @Test + fun `concurrent installs only schedule one legacy flush`() { + val threadCount = 32 + val scheduledFlushes = AtomicInteger() + val start = CountDownLatch(1) + val ready = CountDownLatch(threadCount) + val integrations = + List(threadCount) { + val config = PostHogConfig(API_KEY, host = "host") + val countingExecutor = + object : AbstractExecutorService() { + override fun execute(command: Runnable) { + scheduledFlushes.incrementAndGet() + } + + override fun shutdown() { + } + + override fun shutdownNow(): List = emptyList() + + override fun isShutdown(): Boolean = false + + override fun isTerminated(): Boolean = false + + override fun awaitTermination( + timeout: Long, + unit: TimeUnit, + ): Boolean = true + } + PostHogSendCachedEventsIntegration(config, PostHogApi(config), countingExecutor) + } + val postHog = mock() + val threads = + integrations.map { integration -> + Thread { + ready.countDown() + start.await() + integration.install(postHog) + }.apply { start() } + } + + ready.await() + start.countDown() + threads.forEach { it.join() } + + assertEquals(1, scheduledFlushes.get()) + integrations.forEach { it.uninstall() } + } + @Test fun `install bails out if not connected`() { val storagePrefix = writeFile(listOf(event)) From c279b8ee0382a9e3ad330c6cb17475729d4e7913 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 29 Jul 2026 07:08:37 +0200 Subject: [PATCH 2/4] fix: preserve integration ownership during teardown --- ...HogActivityLifecycleCallbackIntegration.kt | 17 ++++++++-- .../internal/PostHogAppInstallIntegration.kt | 9 ++++++ .../PostHogLifecycleObserverIntegration.kt | 11 ++++++- .../PostHogTouchActivityIntegration.kt | 9 ++++++ .../replay/PostHogReplayIntegration.kt | 11 ++++++- .../internal/PostHogLogCatIntegration.kt | 17 ++++++++-- ...ctivityLifecycleCallbackIntegrationTest.kt | 31 +++++++++++++++++-- .../replay/PostHogReplayIntegrationTest.kt | 1 + ...tHogErrorTrackingAutoCaptureIntegration.kt | 15 +++++++-- .../PostHogSendCachedEventsIntegration.kt | 9 ++++++ .../PostHogSendCachedEventsIntegrationTest.kt | 7 +++-- 11 files changed, 122 insertions(+), 15 deletions(-) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt index 552e140f6..4de7b7f58 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt @@ -19,6 +19,7 @@ internal class PostHogActivityLifecycleCallbackIntegration( private val config: PostHogAndroidConfig, ) : ActivityLifecycleCallbacks, PostHogIntegration { private var postHog: PostHogInterface? = null + private var ownsInstallation = false private companion object { private val integrationInstalled = AtomicBoolean(false) @@ -83,18 +84,28 @@ internal class PostHogActivityLifecycleCallbackIntegration( override fun onActivityDestroyed(activity: Activity) { } + @Synchronized override fun install(postHog: PostHogInterface) { if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true this.postHog = postHog application.registerActivityLifecycleCallbacks(this) } + @Synchronized override fun uninstall() { - this.postHog = null - integrationInstalled.set(false) - application.unregisterActivityLifecycleCallbacks(this) + if (!ownsInstallation) { + return + } + try { + this.postHog = null + application.unregisterActivityLifecycleCallbacks(this) + } finally { + ownsInstallation = false + integrationInstalled.set(false) + } } } diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt index cf7d12d45..03ec187de 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAppInstallIntegration.kt @@ -17,10 +17,13 @@ internal class PostHogAppInstallIntegration( private val context: Context, private val config: PostHogAndroidConfig, ) : PostHogIntegration { + private var ownsInstallation = false + private companion object { private val integrationInstalled = AtomicBoolean(false) } + @Synchronized override fun install(postHog: PostHogInterface) { // While the store is unreadable (Direct Boot) VERSION/BUILD read as absent, which would // fire a spurious "Application Installed" for an existing install and overwrite the @@ -32,6 +35,7 @@ internal class PostHogAppInstallIntegration( if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true getPackageInfo(context, config)?.let { packageInfo -> config.cachePreferences?.let { preferences -> @@ -73,7 +77,12 @@ internal class PostHogAppInstallIntegration( } } + @Synchronized override fun uninstall() { + if (!ownsInstallation) { + return + } + ownsInstallation = false integrationInstalled.set(false) } } 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 26f74c539..1c648c0ee 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 @@ -33,6 +33,7 @@ internal class PostHogLifecycleObserverIntegration( private val bgEndSessionDelayMs = (1000 * 60 * 30).toLong() // 30 minutes private var postHog: PostHogInterface? = null + private var ownsInstallation = false private companion object { // in case there are multiple instances or the SDK is closed/setup again @@ -124,10 +125,12 @@ internal class PostHogLifecycleObserverIntegration( lifecycle.addObserver(this) } + @Synchronized override fun install(postHog: PostHogInterface) { if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true try { this.postHog = postHog @@ -147,9 +150,12 @@ internal class PostHogLifecycleObserverIntegration( lifecycle.removeObserver(this) } + @Synchronized override fun uninstall() { + if (!ownsInstallation) { + return + } try { - integrationInstalled.set(false) this.postHog = null if (isMainThread(mainHandler)) { remove() @@ -160,6 +166,9 @@ internal class PostHogLifecycleObserverIntegration( } } catch (e: Throwable) { config.logger.log("Failed to uninstall PostHogLifecycleObserverIntegration: $e") + } finally { + ownsInstallation = false + integrationInstalled.set(false) } } } 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 61625c8e7..c14830641 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 @@ -23,6 +23,8 @@ import java.util.concurrent.atomic.AtomicBoolean internal class PostHogTouchActivityIntegration( private val config: PostHogAndroidConfig, ) : PostHogIntegration { + private var ownsInstallation = false + private companion object { private val integrationInstalled = AtomicBoolean(false) } @@ -53,10 +55,12 @@ internal class PostHogTouchActivityIntegration( } } + @Synchronized override fun install(postHog: PostHogInterface) { if (!isSupported() || !integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true try { Curtains.rootViews.forEach { view -> view.phoneWindow?.let { window -> @@ -71,7 +75,11 @@ internal class PostHogTouchActivityIntegration( } } + @Synchronized override fun uninstall() { + if (!ownsInstallation) { + return + } try { Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener Curtains.rootViews.forEach { view -> @@ -82,6 +90,7 @@ internal class PostHogTouchActivityIntegration( } catch (e: Throwable) { config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") } finally { + ownsInstallation = false integrationInstalled.set(false) } } 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 25fb83a1d..17352c620 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 @@ -192,6 +192,7 @@ public class PostHogReplayIntegration( private var postHog: PostHogInterface? = null private var replayQueue: PostHogReplayQueue? = null + private var ownsInstallation = false @Volatile private var replaySessionId: String? = null @@ -525,10 +526,12 @@ public class PostHogReplayIntegration( decorViews.remove(view) } + @Synchronized override fun install(postHog: PostHogInterface) { if (!isSupported() || !integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true this.postHog = postHog // Wire up as buffer delegate for the replay queue @@ -558,9 +561,12 @@ public class PostHogReplayIntegration( } } + @Synchronized override fun uninstall() { + if (!ownsInstallation) { + return + } try { - integrationInstalled.set(false) this.postHog = null // Clear buffer delegate @@ -589,6 +595,9 @@ public class PostHogReplayIntegration( decorViews.clear() } catch (e: Throwable) { config.logger.log("Session Replay uninstall failed: $e.") + } finally { + ownsInstallation = false + integrationInstalled.set(false) } } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt index 0ec0a90c4..ec0a02ff4 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt @@ -21,11 +21,13 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig get() = postHog?.isSessionReplayActive() ?: false private var postHog: PostHogInterface? = null + private var ownsInstallation = false private companion object { private val integrationInstalled = AtomicBoolean(false) } + @Synchronized override fun install(postHog: PostHogInterface) { this.postHog = postHog val captureLogcat = config.remoteConfigHolder?.isConsoleLogRecordingEnabled() ?: true @@ -35,6 +37,7 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true val cmd = mutableListOf("logcat", "-v", "threadtime", "*:E") val sdf = SimpleDateFormat("MM-dd HH:mm:ss.mmm", Locale.ROOT) cmd.add("-T") @@ -108,9 +111,17 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig @PostHogVisibleForTesting internal fun isInstalled(): Boolean = integrationInstalled.get() + @Synchronized override fun uninstall() { - integrationInstalled.set(false) - logcatInProgress = false - logcatThread?.interruptSafely() + if (!ownsInstallation) { + return + } + try { + logcatInProgress = false + logcatThread?.interruptSafely() + } finally { + ownsInstallation = false + integrationInstalled.set(false) + } } } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index 42d52c321..ffee96216 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -62,8 +62,33 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { start.countDown() threads.forEach { it.join() } - verify(application, times(1)).registerActivityLifecycleCallbacks(any()) - integrations.forEach { it.uninstall() } + try { + verify(application, times(1)).registerActivityLifecycleCallbacks(any()) + } finally { + integrations.forEach { it.uninstall() } + } + } + + @Test + fun `uninstall from a non-owner keeps the winning installation`() { + val owner = getSut() + val nonOwner = getSut() + val laterInstance = getSut() + val fake = createPostHogFake() + + try { + owner.install(fake) + nonOwner.install(fake) + nonOwner.uninstall() + laterInstance.install(fake) + + verify(application, times(1)).registerActivityLifecycleCallbacks(any()) + verify(application, never()).unregisterActivityLifecycleCallbacks(any()) + } finally { + owner.uninstall() + nonOwner.uninstall() + laterInstance.uninstall() + } } @Test @@ -82,7 +107,9 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { @Test fun `uninstall unregisters the lifecycle callback`() { val sut = getSut() + val fake = createPostHogFake() + sut.install(fake) sut.uninstall() verify(application).unregisterActivityLifecycleCallbacks(any()) 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 294d750b8..2b78605c1 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 @@ -1156,6 +1156,7 @@ internal class PostHogReplayIntegrationTest { // modification — so an unsynchronized map can corrupt under this race (lost entries or an // infinite bucket-chain loop; the timeout catches the latter). val sut = getSut() + sut.install(mock()) val appContext = ApplicationProvider.getApplicationContext() val listener = mock() val probe = View(appContext) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index f41a7cdf5..e201b0866 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -12,6 +12,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th private val adapterExceptionHandler: UncaughtExceptionHandlerAdapter private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null private var postHog: PostHogInterface? = null + private var ownsInstallation = false public constructor(config: PostHogConfig) { this.config = config @@ -27,6 +28,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th private val integrationInstalled = AtomicBoolean(false) } + @Synchronized override fun install(postHog: PostHogInterface) { this.postHog = postHog @@ -57,16 +59,23 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) config.logger.log("Exception autocapture is enabled.") } + @Synchronized override fun uninstall() { - if (!integrationInstalled.compareAndSet(true, false)) { + if (!ownsInstallation) { return } - adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) - config.logger.log("Exception autocapture is disabled.") + try { + adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) + config.logger.log("Exception autocapture is disabled.") + } finally { + ownsInstallation = false + integrationInstalled.set(false) + } } override fun onRemoteConfig(loaded: Boolean) { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt index 478ef93f3..cfd8eed83 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt @@ -20,14 +20,18 @@ internal class PostHogSendCachedEventsIntegration( private val api: PostHogApi, private val executor: ExecutorService, ) : PostHogIntegration { + private var ownsInstallation = false + private companion object { private val integrationInstalled = AtomicBoolean(false) } + @Synchronized override fun install(postHog: PostHogInterface) { if (!integrationInstalled.compareAndSet(false, true)) { return } + ownsInstallation = true executor.executeSafely { if (config.networkStatus?.isConnected() == false) { @@ -137,7 +141,12 @@ internal class PostHogSendCachedEventsIntegration( iterator.remove() } + @Synchronized override fun uninstall() { + if (!ownsInstallation) { + return + } + ownsInstallation = false integrationInstalled.set(false) } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt index ba7047241..41bd24cb9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt @@ -106,8 +106,11 @@ internal class PostHogSendCachedEventsIntegrationTest { start.countDown() threads.forEach { it.join() } - assertEquals(1, scheduledFlushes.get()) - integrations.forEach { it.uninstall() } + try { + assertEquals(1, scheduledFlushes.get()) + } finally { + integrations.forEach { it.uninstall() } + } } @Test From b65acae6ca09518e49bd84a2ffd48c93ae62fb5c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 29 Jul 2026 07:38:05 +0200 Subject: [PATCH 3/4] test: isolate cached integration installation state --- .../internal/PostHogSendCachedEventsIntegration.kt | 8 +++++++- .../internal/PostHogSendCachedEventsIntegrationTest.kt | 7 +++++++ .../PostHogSendCachedLegacyEventsIntegrationTest.kt | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt index cfd8eed83..f217d3e29 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt @@ -4,6 +4,7 @@ import com.posthog.PostHogConfig import com.posthog.PostHogEvent import com.posthog.PostHogIntegration import com.posthog.PostHogInterface +import com.posthog.PostHogVisibleForTesting import java.io.File import java.io.IOException import java.util.concurrent.ExecutorService @@ -22,8 +23,13 @@ internal class PostHogSendCachedEventsIntegration( ) : PostHogIntegration { private var ownsInstallation = false - private companion object { + internal companion object { private val integrationInstalled = AtomicBoolean(false) + + @PostHogVisibleForTesting + internal fun resetInstallationForTesting() { + integrationInstalled.set(false) + } } @Synchronized diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt index 41bd24cb9..1384b0f10 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt @@ -15,6 +15,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -42,8 +43,14 @@ internal class PostHogSendCachedEventsIntegrationTest { return PostHogSendCachedEventsIntegration(config, api, executor = executor) } + @BeforeTest + fun `set up`() { + PostHogSendCachedEventsIntegration.resetInstallationForTesting() + } + @AfterTest fun `set down`() { + PostHogSendCachedEventsIntegration.resetInstallationForTesting() tmpDir.root.deleteRecursively() } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt index 68038735e..7f5626169 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt @@ -12,6 +12,7 @@ import org.mockito.Mockito.mock import java.io.File import java.util.concurrent.Executors import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -42,8 +43,14 @@ internal class PostHogSendCachedLegacyEventsIntegrationTest { return PostHogSendCachedEventsIntegration(config, api, executor = executor) } + @BeforeTest + fun `set up`() { + PostHogSendCachedEventsIntegration.resetInstallationForTesting() + } + @AfterTest fun `set down`() { + PostHogSendCachedEventsIntegration.resetInstallationForTesting() tmpDir.root.deleteRecursively() } From f2d54d0a6383b9fa7bacfce006fe62970e7c2771 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 29 Jul 2026 11:13:18 +0200 Subject: [PATCH 4/4] fix: shut down unused cached event executors --- .../com/posthog/internal/PostHogSendCachedEventsIntegration.kt | 1 + .../posthog/internal/PostHogSendCachedEventsIntegrationTest.kt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt index f217d3e29..009b7368e 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt @@ -35,6 +35,7 @@ internal class PostHogSendCachedEventsIntegration( @Synchronized override fun install(postHog: PostHogInterface) { if (!integrationInstalled.compareAndSet(false, true)) { + executor.shutdown() return } ownsInstallation = true diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt index 1384b0f10..530fa0e93 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedEventsIntegrationTest.kt @@ -72,6 +72,7 @@ internal class PostHogSendCachedEventsIntegrationTest { fun `concurrent installs only schedule one legacy flush`() { val threadCount = 32 val scheduledFlushes = AtomicInteger() + val shutdownExecutors = AtomicInteger() val start = CountDownLatch(1) val ready = CountDownLatch(threadCount) val integrations = @@ -84,6 +85,7 @@ internal class PostHogSendCachedEventsIntegrationTest { } override fun shutdown() { + shutdownExecutors.incrementAndGet() } override fun shutdownNow(): List = emptyList() @@ -115,6 +117,7 @@ internal class PostHogSendCachedEventsIntegrationTest { try { assertEquals(1, scheduledFlushes.get()) + assertEquals(threadCount, shutdownExecutors.get()) } finally { integrations.forEach { it.uninstall() } }