From ccc753885beab3d4c917471206ddfb7d1ce1326e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 20:52:06 -0400 Subject: [PATCH 1/7] feat(push): capture a launch intent the SDK was installed too late to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray intent is read in onActivityCreated, and install() only registers lifecycle callbacks — it never seeds from an Activity that already exists. A host that configures the SDK from its own runtime (Flutter and React Native reach setup() from Dart/JS) installs after the launch Activity has created, started and resumed, so no callback ever fires for it. Adds PostHogAndroid.capturePushNotificationOpened(intent) for those hosts, sharing the extraction and message-id dedupe with the automatic path. On that path the id is also persisted, because a caller with no savedInstanceState cannot tell a process-kill restore from a real re-tap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .changeset/push-open-late-install.md | 8 + posthog-android/api/posthog-android.api | 1 + .../com/posthog/android/PostHogAndroid.kt | 55 +++++++ ...HogActivityLifecycleCallbackIntegration.kt | 137 ++++++++++++----- .../com/posthog/android/PostHogAndroidTest.kt | 39 +++++ .../test/java/com/posthog/android/Utils.kt | 1 + ...ctivityLifecycleCallbackIntegrationTest.kt | 141 +++++++++++++++++- posthog/api/posthog.api | 2 + posthog/src/main/java/com/posthog/PostHog.kt | 15 +- .../posthog/internal/PostHogPreferences.kt | 4 + .../PostHogPreferencesInternalKeysTest.kt | 20 +++ .../java/com/posthog/PostHogFake.kt | 6 +- 12 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 .changeset/push-open-late-install.md create mode 100644 posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt diff --git a/.changeset/push-open-late-install.md b/.changeset/push-open-late-install.md new file mode 100644 index 000000000..845b4c8d6 --- /dev/null +++ b/.changeset/push-open-late-install.md @@ -0,0 +1,8 @@ +--- +'posthog': minor +'posthog-android': minor +'posthog-server': patch +'posthog-android-surveys-compose': patch +--- + +Add `PostHogAndroid.capturePushNotificationOpened(intent)` to capture `$push_notification_opened` for a launch intent the SDK was installed too late to read. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 80883e1e6..bed5d7ffd 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -11,6 +11,7 @@ public final class com/posthog/android/PostHogAndroid { } public final class com/posthog/android/PostHogAndroid$Companion { + public final fun capturePushNotificationOpened (Landroid/content/Intent;)V public final fun setup (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;)V public final fun with (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;)Lcom/posthog/PostHogInterface; } 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 a21cbee89..e4d014d81 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -2,9 +2,11 @@ package com.posthog.android import android.app.Application import android.content.Context +import android.content.Intent import android.os.Build import com.posthog.PostHog import com.posthog.PostHogInterface +import com.posthog.PostHogVisibleForTesting import com.posthog.android.errortracking.PostHogNativeCrashIntegration import com.posthog.android.internal.MainHandler import com.posthog.android.internal.PostHogActivityLifecycleCallbackIntegration @@ -41,6 +43,54 @@ public class PostHogAndroid private constructor() { public companion object { private val lock = Any() + /** + * Retained so a host can hand the SDK a launch intent after setup; see + * [capturePushNotificationOpened]. Never cleared in production — a `close()` leaves the last + * config here. + */ + @Volatile + private var androidConfig: PostHogAndroidConfig? = null + + @PostHogVisibleForTesting + internal fun resetAndroidConfig() { + androidConfig = null + } + + /** + * Captures `$push_notification_opened` for a notification tap carried on [intent]. + * + * The SDK reads the tray intent when the launch Activity is created. A host that configures + * PostHog from its own runtime — Flutter and React Native reach `setup()` from Dart/JS, after + * the launch Activity has already created, started and resumed — installs too late to see that + * callback, and should pass the Activity's intent here instead. + * + * Deduped by `google.message_id`, so calling it alongside the automatic path cannot + * double-count, including across a process-death restore: on this path the id is remembered on + * disk, because a caller with no `savedInstanceState` cannot otherwise tell a restore — which + * hands the Activity back its original intent — from a real second tap. The cost of that trade + * is that a genuine second tap of the *same* notification after a process restart reads as a + * restore and is dropped; the persisted id is never cleared. + * + * No-op when [intent] is null or carries no push id, when `capturePushNotificationOpened` is + * disabled, or before [setup]. Events go to the shared instance, so a host that only called + * [with] is not served. + * + * Covers launch intents only. A warm-start tap arrives through `Activity.onNewIntent`, which + * nothing here observes — pass that intent in yourself. + */ + public fun capturePushNotificationOpened(intent: Intent?) { + val config = androidConfig ?: return + if (!config.capturePushNotificationOpened) return + val pushIntent = intent ?: return + + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + intent = pushIntent, + postHog = PostHog, + config = config, + usePersistedDedupe = true, + ) + } + /** * Sets up the SDK and stores it as the global singleton. * @@ -55,6 +105,11 @@ public class PostHogAndroid private constructor() { setAndroidConfig(context.appContext(), config) PostHog.setup(config) + + // Only setup() arms the manual entry point: with() builds a secondary instance whose + // config must not decide the gate, or the preferences file, for events that are + // delivered to the shared one. + androidConfig = config } } 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 87b312bfe..5b1385b98 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 @@ -3,10 +3,13 @@ package com.posthog.android.internal import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks +import android.content.Intent import android.os.Bundle import com.posthog.PostHogIntegration import com.posthog.PostHogInterface +import com.posthog.PostHogVisibleForTesting import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID import java.util.concurrent.atomic.AtomicBoolean /** @@ -21,13 +24,101 @@ internal class PostHogActivityLifecycleCallbackIntegration( private var postHog: PostHogInterface? = null private var ownsInstallation = false - @Volatile - private var lastHandledPushMessageId: String? = null - - private companion object { + internal companion object { private val integrationInstalled = AtomicBoolean(false) private const val GOOGLE_MESSAGE_ID = "google.message_id" + + private val pushDedupeLock = Any() + + /** + * Process-wide on purpose: a tap must not be re-captured after a `close()`/`setup()` cycle, + * and the static entry point has no integration instance to hang this on. + */ + private var lastHandledPushMessageId: String? = null + + @PostHogVisibleForTesting + internal fun resetPushDedupe() { + synchronized(pushDedupeLock) { lastHandledPushMessageId = null } + } + + /** + * Captures `$push_notification_opened` for a tray tap carried on [intent], deduped by + * `google.message_id`. Title/body aren't in the tray intent (only the `posthog` JSON extra is). + * + * [usePersistedDedupe] additionally remembers the id on disk, so the dedupe outlives the + * process. Only callers with no restore signal need that: `onActivityCreated` has + * `savedInstanceState`, which is strictly better because it separates a restore from a genuine + * second tap of the same notification — a persisted id cannot tell those apart, and would drop + * the real one. Keeping the write behind the same flag means only hosts that opt in ever + * write this key. + */ + internal fun capturePushNotificationOpened( + intent: Intent, + postHog: PostHogInterface?, + config: PostHogAndroidConfig, + usePersistedDedupe: Boolean = false, + ) { + // Reading extras unmarshals the whole Bundle; a launch intent carrying a + // Serializable/Parcelable extra whose class isn't on this app's classloader throws + // BadParcelableException here. An uncaught throw would surface in a framework callback or + // in host code, either way crashing the app. + try { + val target = postHog ?: return + // Marking an id the SDK will refuse to send would burn it for good, and an opt-in later + // in the session could never recover it. + if (target.isOptOut()) return + + val messageId = intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return + + // Check and mark under one lock: a second caller for the same id must not slip + // through while this one is still delivering. + val payload = + synchronized(pushDedupeLock) { + val persistedId = + if (usePersistedDedupe) { + config.cachePreferences?.getValue(PUSH_LAST_OPENED_MESSAGE_ID) + } else { + null + } + if (messageId == lastHandledPushMessageId || messageId == persistedId) { + lastHandledPushMessageId = messageId + // The automatic path marks memory only. Persisting on the way out of a hit + // keeps a later restore — where that path is gated by savedInstanceState — + // from capturing the same tap a second time. + if (usePersistedDedupe && messageId != persistedId) { + config.cachePreferences?.setValue(PUSH_LAST_OPENED_MESSAGE_ID, messageId) + } + return + } + // Read the risky full Bundle first: if toMap() throws, the id stays unmarked so + // a later activity (e.g. a trampoline) with a clean Bundle can retry. + val extras = intent.extras?.toMap() + lastHandledPushMessageId = messageId + if (usePersistedDedupe) { + config.cachePreferences?.setValue(PUSH_LAST_OPENED_MESSAGE_ID, messageId) + } + extras + } + + target.capturePushNotificationOpened( + title = null, + body = null, + payload = payload, + ) + } catch (e: Throwable) { + config.logger.log("Failed to capture push notification opened: $e.") + } + } + + private fun Bundle.toMap(): Map { + val map = mutableMapOf() + for (key in keySet()) { + @Suppress("DEPRECATION") + map[key] = get(key) + } + return map + } } override fun onActivityCreated( @@ -66,45 +157,9 @@ internal class PostHogActivityLifecycleCallbackIntegration( } } - /** - * Captures `$push_notification_opened` for a cold-start tray tap, detected via the launch intent's - * `google.message_id`. Title/body aren't in the tray intent (only the `posthog` JSON extra is); - * warm-start `onNewIntent` and foreground data messages need the manual API. The message-id guard - * dedupes repeat reads within a process; the caller gates on a fresh launch to skip recreations. - */ private fun capturePushNotificationOpenedIfNeeded(activity: Activity) { val intent = activity.intent ?: return - // Reading extras unmarshals the whole Bundle; a launch intent carrying a Serializable/Parcelable - // extra whose class isn't on this app's classloader throws BadParcelableException here. This runs - // inside the framework onActivityCreated callback, so an uncaught throw crashes the host app. - try { - val messageId = intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return - - if (messageId == lastHandledPushMessageId) { - return - } - // Read the risky full Bundle before marking handled: if toMap() throws, the id must - // stay unmarked so a later activity (e.g. a trampoline) with a clean Bundle can retry. - val payload = intent.extras?.toMap() - lastHandledPushMessageId = messageId - - postHog?.capturePushNotificationOpened( - title = null, - body = null, - payload = payload, - ) - } catch (e: Throwable) { - config.logger.log("Failed to capture push notification opened: $e.") - } - } - - private fun Bundle.toMap(): Map { - val map = mutableMapOf() - for (key in keySet()) { - @Suppress("DEPRECATION") - map[key] = get(key) - } - return map + capturePushNotificationOpened(intent, postHog, config) } override fun onActivityStarted(activity: Activity) { diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 1329d838c..b5b04e7fb 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -1,6 +1,7 @@ package com.posthog.android import android.content.Context +import android.content.Intent import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHog import com.posthog.android.errortracking.PostHogNativeCrashIntegration @@ -14,7 +15,9 @@ import com.posthog.android.internal.PostHogLifecycleObserverIntegration import com.posthog.android.internal.PostHogPushSubscriptionIntegration import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogLogger +import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNetworkStatus +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -38,6 +41,9 @@ internal class PostHogAndroidTest { @BeforeTest fun `set up`() { PostHog.close() + // androidConfig is a never-cleared process-wide static; without this the manual push entry + // stays armed from whichever test ran last. + PostHogAndroid.resetAndroidConfig() } @AfterTest @@ -59,6 +65,39 @@ internal class PostHogAndroidTest { assertTrue(logger.messages.any { it.contains("PostHog SDK is disabled because the API key is required") }) } + @Test + fun `the manual entry is inert before setup and while the feature is disabled`() { + mockContextAppStart(context, tmpDir) + val intent = Intent().putExtra("google.message_id", "manual-entry") + + // Before setup(): no config, so nothing is armed and nothing is persisted. + PostHogAndroid.capturePushNotificationOpened(intent) + + val config = PostHogAndroidConfig(API_KEY).apply { capturePushNotificationOpened = false } + PostHogAndroid.setup(context, config) + // Armed now, but the feature is disabled, so still nothing. + PostHogAndroid.capturePushNotificationOpened(intent) + + assertNull(config.cachePreferences?.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + } + + @Test + fun `with after setup must not redirect the manual entry to the secondary project`() { + mockContextAppStart(context, tmpDir) + val primaryPrefs = PostHogMemoryPreferences() + val secondaryPrefs = PostHogMemoryPreferences() + + PostHogAndroid.setup(context, PostHogAndroidConfig(API_KEY).apply { cachePreferences = primaryPrefs }) + PostHogAndroid.with(context, PostHogAndroidConfig(API_KEY_2).apply { cachePreferences = secondaryPrefs }) + + PostHogAndroid.capturePushNotificationOpened(Intent().putExtra("google.message_id", "probe")) + + // The event goes to the shared instance, so the dedupe id must land in its preferences — + // never in the secondary project's file. + assertEquals("probe", primaryPrefs.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + assertNull(secondaryPrefs.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + } + @Test fun `sets Android Logger if System logger`() { val config = PostHogAndroidConfig(API_KEY) diff --git a/posthog-android/src/test/java/com/posthog/android/Utils.kt b/posthog-android/src/test/java/com/posthog/android/Utils.kt index 255c02da8..c258f849e 100644 --- a/posthog-android/src/test/java/com/posthog/android/Utils.kt +++ b/posthog-android/src/test/java/com/posthog/android/Utils.kt @@ -25,6 +25,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever public const val API_KEY: String = "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI" +public const val API_KEY_2: String = "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnG2" public fun mockActivityUri( uri: String, 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 3b25e4672..e6893794e 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 @@ -12,6 +12,8 @@ import com.posthog.android.PostHogAndroidConfig import com.posthog.android.createPostHogFake import com.posthog.android.mockActivityUri import com.posthog.android.mockScreenTitle +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -33,16 +35,25 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { captureDeepLinks: Boolean = true, captureScreenViews: Boolean = true, capturePushNotificationOpened: Boolean = true, + cachePreferences: PostHogMemoryPreferences? = null, ): PostHogActivityLifecycleCallbackIntegration { - val config = - PostHogAndroidConfig(API_KEY).apply { - this.captureDeepLinks = captureDeepLinks - this.captureScreenViews = captureScreenViews - this.capturePushNotificationOpened = capturePushNotificationOpened - } + val config = buildConfig(captureDeepLinks, captureScreenViews, capturePushNotificationOpened, cachePreferences) return PostHogActivityLifecycleCallbackIntegration(application, config) } + private fun buildConfig( + captureDeepLinks: Boolean = true, + captureScreenViews: Boolean = true, + capturePushNotificationOpened: Boolean = true, + cachePreferences: PostHogMemoryPreferences? = null, + ): PostHogAndroidConfig = + PostHogAndroidConfig(API_KEY).apply { + this.captureDeepLinks = captureDeepLinks + this.captureScreenViews = captureScreenViews + this.capturePushNotificationOpened = capturePushNotificationOpened + this.cachePreferences = cachePreferences + } + private fun mockActivityWithExtras(vararg extras: Pair): Activity { val activity = mock() val intent = @@ -55,6 +66,7 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { @BeforeTest fun `set up`() { + PostHogActivityLifecycleCallbackIntegration.resetPushDedupe() PostHog.resetSharedInstance() } @@ -373,6 +385,123 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { assertEquals(2, fake.pushOpenedCaptures) } + @Test + fun `captures a launch intent handed over after the Activity was already created`() { + val fake = createPostHogFake() + val config = buildConfig() + + // No install(): a host that reaches setup() from Dart or JS installs after the launch + // Activity has created, started and resumed, so no lifecycle callback ever fires for it. + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "late").intent, + fake, + config, + ) + + assertEquals(1, fake.pushOpenedCaptures) + assertEquals("late", fake.pushOpenedPayload?.get("google.message_id")) + } + + @Test + fun `a handed-over intent does not double count against the automatic path`() { + val preferences = PostHogMemoryPreferences() + val sut = getSut() + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(mockActivityWithExtras("google.message_id" to "both"), null) + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "both").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + + // The hand-over must persist the id even though it deduped, or a later restore — where the + // automatic path is gated by savedInstanceState — captures the same tap again. + PostHogActivityLifecycleCallbackIntegration.resetPushDedupe() + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "both").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + sut.uninstall() + + assertEquals(1, fake.pushOpenedCaptures) + } + + @Test + fun `the automatic path still captures a genuine re-tap in a new process`() { + val preferences = PostHogMemoryPreferences() + val fake = createPostHogFake() + + val first = getSut(cachePreferences = preferences) + first.install(fake) + first.onActivityCreated(mockActivityWithExtras("google.message_id" to "retap"), null) + first.uninstall() + + // savedInstanceState is null, so this is a fresh launch rather than a restore. + PostHogActivityLifecycleCallbackIntegration.resetPushDedupe() + val second = getSut(cachePreferences = preferences) + second.install(fake) + second.onActivityCreated(mockActivityWithExtras("google.message_id" to "retap"), null) + second.uninstall() + + assertEquals(2, fake.pushOpenedCaptures) + // The automatic path must never write the persisted key — that is what keeps a pure-native + // app's stored state unchanged by this feature. + assertNull(preferences.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + } + + @Test + fun `an opted-out instance neither captures nor burns the message id`() { + val preferences = PostHogMemoryPreferences() + val optedOut = createPostHogFake().also { it.optOut() } + + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "optedout").intent, + optedOut, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + assertEquals(0, optedOut.pushOpenedCaptures) + + val optedIn = createPostHogFake() + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "optedout").intent, + optedIn, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + + assertEquals(1, optedIn.pushOpenedCaptures) + } + + @Test + fun `a persisted message id survives process death so a restore is not a second tap`() { + val preferences = PostHogMemoryPreferences() + val fake = createPostHogFake() + + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "restored").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + // A process-kill restore hands the Activity back its original intent with the in-memory + // guard gone; only the persisted id can tell that apart from a fresh tap. + PostHogActivityLifecycleCallbackIntegration.resetPushDedupe() + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "restored").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + + assertEquals(1, fake.pushOpenedCaptures) + } + @Test fun `onActivityCreated does not capture push when no google message id`() { val sut = getSut() diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ec32a74d9..48b8a866b 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -914,6 +914,7 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; + public static final field PUSH_LAST_OPENED_MESSAGE_ID Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; @@ -932,6 +933,7 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; + public static final field PUSH_LAST_OPENED_MESSAGE_ID Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index d325ec780..13be63acf 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -19,6 +19,7 @@ import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.internal.PostHogPreferences.Companion.VERSION @@ -1927,7 +1928,19 @@ public class PostHog private constructor( // stable feature flag bucketing across identity changes. // Preserve SESSION_REPLAY, ERROR_TRACKING, CAPTURE_PERFORMANCE, and SURVEYS (project-level config // from /config, not user data) so each survives an identity change without an app restart. - val except = mutableListOf(VERSION, BUILD, DEVICE_ID, SESSION_REPLAY, ERROR_TRACKING, CAPTURE_PERFORMANCE, SURVEYS) + // Preserve PUSH_LAST_OPENED_MESSAGE_ID for the same reason: it is device state that stops one + // notification tap being counted twice, so clearing it would re-enable a duplicate. + val except = + mutableListOf( + VERSION, + BUILD, + DEVICE_ID, + SESSION_REPLAY, + ERROR_TRACKING, + CAPTURE_PERFORMANCE, + SURVEYS, + PUSH_LAST_OPENED_MESSAGE_ID, + ) // preserve the ANONYMOUS_ID if reuseAnonymousId is enabled (for preserving a guest user // account on the device) if (config?.reuseAnonymousId == true) { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 46e455401..32043afec 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -52,6 +52,9 @@ public interface PostHogPreferences { internal const val ERROR_TRACKING = "errorTracking" internal const val CAPTURE_PERFORMANCE = "capturePerformance" internal const val PUSH = "push" + + @PostHogInternal + public const val PUSH_LAST_OPENED_MESSAGE_ID: String = "pushLastOpenedMessageId" internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" internal const val GROUP_PROPERTIES_FOR_FLAGS = "groupPropertiesForFlags" public const val SURVEY_SEEN: String = "surveySeen" @@ -63,6 +66,7 @@ public interface PostHogPreferences { public val ALL_INTERNAL_KEYS: Set = setOf( + PUSH_LAST_OPENED_MESSAGE_ID, GROUPS, ANONYMOUS_ID, DISTINCT_ID, diff --git a/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt b/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt new file mode 100644 index 000000000..d9afaac0d --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt @@ -0,0 +1,20 @@ +package com.posthog + +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogPreferencesInternalKeysTest { + @Test + fun `the push dedupe id is internal storage, not a super property`() { + val preferences = PostHogMemoryPreferences() + preferences.setValue(PUSH_LAST_OPENED_MESSAGE_ID, "0:1700000000%abcdef") + preferences.setValue("aUserProperty", "kept") + + // getAll() feeds PostHog.buildProperties(), so anything not filtered here rides on every event. + assertFalse(preferences.getAll().containsKey(PUSH_LAST_OPENED_MESSAGE_ID)) + assertTrue(preferences.getAll().containsKey("aUserProperty")) + } +} diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index 8fbdca562..603a469b8 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -165,9 +165,13 @@ public class PostHogFake : PostHogInterface { } override fun optIn() { + optedOut = false } + public var optedOut: Boolean = false + override fun optOut() { + optedOut = true } override fun group( @@ -188,7 +192,7 @@ public class PostHogFake : PostHogInterface { } override fun isOptOut(): Boolean { - return false + return optedOut } override fun register( From 41dc5f91c98458d1cbee7da05092b22fc93c6f8e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 21:14:58 -0400 Subject: [PATCH 2/7] test(push): pin that reset() preserves the push dedupe id Reverting the except-list entry previously failed nothing, so a future trim would have let a process-death restore re-capture the same tap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../src/test/java/com/posthog/PostHogTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 5f8ad4f8e..4b5c7440d 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -16,6 +16,7 @@ import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS +import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.internal.PostHogPrintLogger @@ -2387,6 +2388,24 @@ internal class PostHogTest { sut.close() } + @Test + fun `reset preserves the push dedupe id`() { + val http = mockHttp() + val url = http.url("/") + val preferences = PostHogMemoryPreferences() + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = preferences) + + preferences.setValue(PUSH_LAST_OPENED_MESSAGE_ID, "0:1700000000%abcdef") + + sut.reset() + + // Device state, not user data: clearing it would let a process-death restore be counted as a + // second tap of the same notification. + assertEquals("0:1700000000%abcdef", preferences.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + + sut.close() + } + @Test fun `reset session id when reset is called`() { val http = mockHttp() From 11e38b2b383eb07e5a2b835f1aadfb0765ed365f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 10:40:27 -0400 Subject: [PATCH 3/7] fix(push): remember several opened message ids, not just the newest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host that also hands over warm-start intents writes to the same store as the launch intent, and only the launch intent is redelivered after a process death. With a single slot a warm tap displaced the launch id, so a later restore captured the launch tap a second time. Keeps a bounded history instead, and renames the key to match — it is unreleased, so the rename costs nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- ...HogActivityLifecycleCallbackIntegration.kt | 40 ++++++++++++++----- .../com/posthog/android/PostHogAndroidTest.kt | 8 ++-- ...ctivityLifecycleCallbackIntegrationTest.kt | 36 ++++++++++++++++- posthog/api/posthog.api | 4 +- posthog/src/main/java/com/posthog/PostHog.kt | 6 +-- .../posthog/internal/PostHogPreferences.kt | 4 +- .../PostHogPreferencesInternalKeysTest.kt | 6 +-- .../src/test/java/com/posthog/PostHogTest.kt | 6 +-- 8 files changed, 80 insertions(+), 30 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 5b1385b98..cca012624 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 @@ -9,7 +9,7 @@ import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.PostHogVisibleForTesting import com.posthog.android.PostHogAndroidConfig -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import java.util.concurrent.atomic.AtomicBoolean /** @@ -29,6 +29,14 @@ internal class PostHogActivityLifecycleCallbackIntegration( private const val GOOGLE_MESSAGE_ID = "google.message_id" + /** + * A launch intent and a warm-start intent are deduped against the same store, and only the + * launch one is redelivered after a process death — so remembering just the newest id would + * let a warm tap displace it and a later restore re-capture the launch tap. + */ + private const val PUSH_ID_HISTORY = 5 + private const val PUSH_ID_SEPARATOR = "\n" + private val pushDedupeLock = Any() /** @@ -75,19 +83,15 @@ internal class PostHogActivityLifecycleCallbackIntegration( // through while this one is still delivering. val payload = synchronized(pushDedupeLock) { - val persistedId = - if (usePersistedDedupe) { - config.cachePreferences?.getValue(PUSH_LAST_OPENED_MESSAGE_ID) - } else { - null - } - if (messageId == lastHandledPushMessageId || messageId == persistedId) { + val persistedIds = + if (usePersistedDedupe) persistedPushIds(config) else emptyList() + if (messageId == lastHandledPushMessageId || messageId in persistedIds) { lastHandledPushMessageId = messageId // The automatic path marks memory only. Persisting on the way out of a hit // keeps a later restore — where that path is gated by savedInstanceState — // from capturing the same tap a second time. - if (usePersistedDedupe && messageId != persistedId) { - config.cachePreferences?.setValue(PUSH_LAST_OPENED_MESSAGE_ID, messageId) + if (usePersistedDedupe && messageId !in persistedIds) { + rememberPushId(config, messageId) } return } @@ -96,7 +100,7 @@ internal class PostHogActivityLifecycleCallbackIntegration( val extras = intent.extras?.toMap() lastHandledPushMessageId = messageId if (usePersistedDedupe) { - config.cachePreferences?.setValue(PUSH_LAST_OPENED_MESSAGE_ID, messageId) + rememberPushId(config, messageId) } extras } @@ -111,6 +115,20 @@ internal class PostHogActivityLifecycleCallbackIntegration( } } + private fun persistedPushIds(config: PostHogAndroidConfig): List = + (config.cachePreferences?.getValue(PUSH_OPENED_MESSAGE_IDS) as? String) + ?.split(PUSH_ID_SEPARATOR) + ?.filter { it.isNotEmpty() } + ?: emptyList() + + private fun rememberPushId( + config: PostHogAndroidConfig, + messageId: String, + ) { + val ids = (listOf(messageId) + persistedPushIds(config)).distinct().take(PUSH_ID_HISTORY) + config.cachePreferences?.setValue(PUSH_OPENED_MESSAGE_IDS, ids.joinToString(PUSH_ID_SEPARATOR)) + } + private fun Bundle.toMap(): Map { val map = mutableMapOf() for (key in keySet()) { diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index b5b04e7fb..670db6b53 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -17,7 +17,7 @@ import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogLogger import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNetworkStatus -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -78,7 +78,7 @@ internal class PostHogAndroidTest { // Armed now, but the feature is disabled, so still nothing. PostHogAndroid.capturePushNotificationOpened(intent) - assertNull(config.cachePreferences?.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + assertNull(config.cachePreferences?.getValue(PUSH_OPENED_MESSAGE_IDS)) } @Test @@ -94,8 +94,8 @@ internal class PostHogAndroidTest { // The event goes to the shared instance, so the dedupe id must land in its preferences — // never in the secondary project's file. - assertEquals("probe", primaryPrefs.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) - assertNull(secondaryPrefs.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + assertEquals("probe", primaryPrefs.getValue(PUSH_OPENED_MESSAGE_IDS)) + assertNull(secondaryPrefs.getValue(PUSH_OPENED_MESSAGE_IDS)) } @Test 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 e6893794e..c0f9e9488 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,7 @@ import com.posthog.android.createPostHogFake import com.posthog.android.mockActivityUri import com.posthog.android.mockScreenTitle import com.posthog.internal.PostHogMemoryPreferences -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -451,7 +451,39 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { assertEquals(2, fake.pushOpenedCaptures) // The automatic path must never write the persisted key — that is what keeps a pure-native // app's stored state unchanged by this feature. - assertNull(preferences.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + assertNull(preferences.getValue(PUSH_OPENED_MESSAGE_IDS)) + } + + @Test + fun `a warm tap does not displace the launch id, so a restore is still deduped`() { + val preferences = PostHogMemoryPreferences() + val fake = createPostHogFake() + + // Cold launch from notification A, then a tap of notification B while the app is running. + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "A").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "B").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + assertEquals(2, fake.pushOpenedCaptures) + + // Process dies; the Activity is restored with its original launch intent A. + PostHogActivityLifecycleCallbackIntegration.resetPushDedupe() + PostHogActivityLifecycleCallbackIntegration.capturePushNotificationOpened( + mockActivityWithExtras("google.message_id" to "A").intent, + fake, + buildConfig(cachePreferences = preferences), + usePersistedDedupe = true, + ) + + assertEquals(2, fake.pushOpenedCaptures) } @Test diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 48b8a866b..464b75c52 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -914,7 +914,7 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; - public static final field PUSH_LAST_OPENED_MESSAGE_ID Ljava/lang/String; + public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; @@ -933,7 +933,7 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; - public static final field PUSH_LAST_OPENED_MESSAGE_ID Ljava/lang/String; + public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 13be63acf..ea7cf3753 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -19,7 +19,7 @@ import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.internal.PostHogPreferences.Companion.VERSION @@ -1928,7 +1928,7 @@ public class PostHog private constructor( // stable feature flag bucketing across identity changes. // Preserve SESSION_REPLAY, ERROR_TRACKING, CAPTURE_PERFORMANCE, and SURVEYS (project-level config // from /config, not user data) so each survives an identity change without an app restart. - // Preserve PUSH_LAST_OPENED_MESSAGE_ID for the same reason: it is device state that stops one + // Preserve PUSH_OPENED_MESSAGE_IDS for the same reason: it is device state that stops one // notification tap being counted twice, so clearing it would re-enable a duplicate. val except = mutableListOf( @@ -1939,7 +1939,7 @@ public class PostHog private constructor( ERROR_TRACKING, CAPTURE_PERFORMANCE, SURVEYS, - PUSH_LAST_OPENED_MESSAGE_ID, + PUSH_OPENED_MESSAGE_IDS, ) // preserve the ANONYMOUS_ID if reuseAnonymousId is enabled (for preserving a guest user // account on the device) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 32043afec..32dc483f2 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -54,7 +54,7 @@ public interface PostHogPreferences { internal const val PUSH = "push" @PostHogInternal - public const val PUSH_LAST_OPENED_MESSAGE_ID: String = "pushLastOpenedMessageId" + public const val PUSH_OPENED_MESSAGE_IDS: String = "pushOpenedMessageIds" internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" internal const val GROUP_PROPERTIES_FOR_FLAGS = "groupPropertiesForFlags" public const val SURVEY_SEEN: String = "surveySeen" @@ -66,7 +66,7 @@ public interface PostHogPreferences { public val ALL_INTERNAL_KEYS: Set = setOf( - PUSH_LAST_OPENED_MESSAGE_ID, + PUSH_OPENED_MESSAGE_IDS, GROUPS, ANONYMOUS_ID, DISTINCT_ID, diff --git a/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt b/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt index d9afaac0d..910cd6fcf 100644 --- a/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogPreferencesInternalKeysTest.kt @@ -1,7 +1,7 @@ package com.posthog import com.posthog.internal.PostHogMemoryPreferences -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -10,11 +10,11 @@ internal class PostHogPreferencesInternalKeysTest { @Test fun `the push dedupe id is internal storage, not a super property`() { val preferences = PostHogMemoryPreferences() - preferences.setValue(PUSH_LAST_OPENED_MESSAGE_ID, "0:1700000000%abcdef") + preferences.setValue(PUSH_OPENED_MESSAGE_IDS, "0:1700000000%abcdef") preferences.setValue("aUserProperty", "kept") // getAll() feeds PostHog.buildProperties(), so anything not filtered here rides on every event. - assertFalse(preferences.getAll().containsKey(PUSH_LAST_OPENED_MESSAGE_ID)) + assertFalse(preferences.getAll().containsKey(PUSH_OPENED_MESSAGE_IDS)) assertTrue(preferences.getAll().containsKey("aUserProperty")) } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 4b5c7440d..334203330 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -16,7 +16,7 @@ import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS -import com.posthog.internal.PostHogPreferences.Companion.PUSH_LAST_OPENED_MESSAGE_ID +import com.posthog.internal.PostHogPreferences.Companion.PUSH_OPENED_MESSAGE_IDS import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.internal.PostHogPrintLogger @@ -2395,13 +2395,13 @@ internal class PostHogTest { val preferences = PostHogMemoryPreferences() val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = preferences) - preferences.setValue(PUSH_LAST_OPENED_MESSAGE_ID, "0:1700000000%abcdef") + preferences.setValue(PUSH_OPENED_MESSAGE_IDS, "0:1700000000%abcdef") sut.reset() // Device state, not user data: clearing it would let a process-death restore be counted as a // second tap of the same notification. - assertEquals("0:1700000000%abcdef", preferences.getValue(PUSH_LAST_OPENED_MESSAGE_ID)) + assertEquals("0:1700000000%abcdef", preferences.getValue(PUSH_OPENED_MESSAGE_IDS)) sut.close() } From 7207af7e9e0f4458ea7d890c3788dba2f11a61fd Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 11:22:48 -0400 Subject: [PATCH 4/7] docs(push): show the host-side warm-start hook in the sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android gives libraries no way to observe Activity.onNewIntent, so the new API only helps if the host forwards it. The sample now does, and declares singleTop — without it the system resumes the task instead of delivering the intent, and the snippet would never fire. Also corrects two KDoc sentences written for the single-id design. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../main/java/com/posthog/android/PostHogAndroid.kt | 10 +++++----- .../src/main/AndroidManifest.xml | 1 + .../java/com/posthog/android/sample/NormalActivity.kt | 9 +++++++++ 3 files changed, 15 insertions(+), 5 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 e4d014d81..6d115713c 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -65,11 +65,11 @@ public class PostHogAndroid private constructor() { * callback, and should pass the Activity's intent here instead. * * Deduped by `google.message_id`, so calling it alongside the automatic path cannot - * double-count, including across a process-death restore: on this path the id is remembered on - * disk, because a caller with no `savedInstanceState` cannot otherwise tell a restore — which - * hands the Activity back its original intent — from a real second tap. The cost of that trade - * is that a genuine second tap of the *same* notification after a process restart reads as a - * restore and is dropped; the persisted id is never cleared. + * double-count, including across a process-death restore: on this path recently opened ids are + * remembered on disk, because a caller with no `savedInstanceState` cannot otherwise tell a + * restore — which hands the Activity back its original intent — from a real second tap. The + * cost of that trade is that a genuine second tap of the *same* notification after a process + * restart reads as a restore and is dropped, until that id ages out of the remembered set. * * No-op when [intent] is null or carries no push id, when `capturePushNotificationOpened` is * disabled, or before [setup]. Events go to the shared instance, so a host that only called diff --git a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml index 47108cd86..ac11a2d29 100644 --- a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml +++ b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt index 004a3139c..b6a40a280 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt @@ -7,6 +7,7 @@ import android.widget.Toast import androidx.activity.ComponentActivity import com.posthog.PostHog import com.posthog.PostHogOkHttpInterceptor +import com.posthog.android.PostHogAndroid import okhttp3.OkHttpClient import okhttp3.internal.closeQuietly @@ -16,6 +17,14 @@ class NormalActivity : ComponentActivity() { .addInterceptor(PostHogOkHttpInterceptor(captureNetworkTelemetry = true)) .build() + // A tap while the app is already running arrives here, not through the SDK's lifecycle + // callbacks — Android gives libraries no way to observe it, so the host forwards it. + // Cold starts need no code: the SDK reads the launch intent itself. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + PostHogAndroid.capturePushNotificationOpened(intent) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From f10bbf753290347bcd8654b23cedd750c9004276 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 12:01:47 -0400 Subject: [PATCH 5/7] docs(push): trim an over-explained comment Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../PostHogActivityLifecycleCallbackIntegration.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 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 cca012624..242581a49 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 @@ -29,11 +29,8 @@ internal class PostHogActivityLifecycleCallbackIntegration( private const val GOOGLE_MESSAGE_ID = "google.message_id" - /** - * A launch intent and a warm-start intent are deduped against the same store, and only the - * launch one is redelivered after a process death — so remembering just the newest id would - * let a warm tap displace it and a later restore re-capture the launch tap. - */ + /** Only the launch intent is redelivered after a process death, so a warm tap must not + * displace it. */ private const val PUSH_ID_HISTORY = 5 private const val PUSH_ID_SEPARATOR = "\n" From 2831ee324c5af4b813acc29868ae3046f90dd559 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 7 Sep 2026 21:49:19 -0400 Subject: [PATCH 6/7] docs(push): point warm-start users at the deduped overload The config KDoc named capturePushNotificationOpened without saying which one: the Intent overload dedupes against the automatic path, the title/body/payload one does not. Wiring the latter into onNewIntent double-counts every warm tap. Also names the PostHogFake fixture change in the changeset, since that artifact is published. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .changeset/push-open-late-install.md | 2 +- .../main/java/com/posthog/android/PostHogAndroidConfig.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/push-open-late-install.md b/.changeset/push-open-late-install.md index 845b4c8d6..9e9ba1919 100644 --- a/.changeset/push-open-late-install.md +++ b/.changeset/push-open-late-install.md @@ -5,4 +5,4 @@ 'posthog-android-surveys-compose': patch --- -Add `PostHogAndroid.capturePushNotificationOpened(intent)` to capture `$push_notification_opened` for a launch intent the SDK was installed too late to read. +Add `PostHogAndroid.capturePushNotificationOpened(intent)` to capture `$push_notification_opened` for a launch intent the SDK was installed too late to read. In the published test fixtures, `PostHogFake.optOut()` and `optIn()` now change what `isOptOut()` returns, where they were previously no-ops. diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt index f3508dffa..3910401b6 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt @@ -28,8 +28,11 @@ import com.posthog.internal.PostHogQueue * still needs the app to forward `onNewToken` to `registerPushNotificationToken(...)`. Default: `true`. * @property capturePushNotificationOpened Whether to auto-capture `$push_notification_opened` for * cold-start taps on a tray notification (detected via the launch intent's `google.message_id` - * extra). Foreground data messages and warm-start `onNewIntent` are not observable here — forward - * those to `capturePushNotificationOpened(...)` manually. Default: `true`. + * extra). A warm-start tap arrives at `Activity.onNewIntent`, which is not observable here — + * forward that intent to [PostHogAndroid.capturePushNotificationOpened], which is deduped against + * this path. Foreground data messages and push delivered outside FCM need + * [PostHog.capturePushNotificationOpened], which is not. Also gates + * [PostHogAndroid.capturePushNotificationOpened]. Default: `true`. */ public open class PostHogAndroidConfig @JvmOverloads From 702b649cf3e924089c52911cf25f81e4e7f7fb9c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 8 Sep 2026 10:28:35 -0400 Subject: [PATCH 7/7] fix(push): ignore a config the core rejected at setup() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog.setup() returns early when an instance is already active, and again on an empty API key — both before it assigns this.config. The Android wrapper adopted the config regardless, so the manual push-open entry would read the rejected config's feature gate and write dedupe ids into its preferences while events kept going to the original instance. Adopt it only when the core actually took it, by identity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../com/posthog/android/PostHogAndroid.kt | 8 +++++-- .../com/posthog/android/PostHogAndroidTest.kt | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 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 6d115713c..ec164192c 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -108,8 +108,12 @@ public class PostHogAndroid private constructor() { // Only setup() arms the manual entry point: with() builds a secondary instance whose // config must not decide the gate, or the preferences file, for events that are - // delivered to the shared one. - androidConfig = config + // delivered to the shared one. The identity check is the other half of that: setup() + // no-ops when an instance is already active or the key was empty, and adopting a + // config it rejected would gate and persist under a project nothing is sent to. + if (PostHog.getConfig() === config) { + androidConfig = config + } } } diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 670db6b53..4873286ab 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -81,6 +81,29 @@ internal class PostHogAndroidTest { assertNull(config.cachePreferences?.getValue(PUSH_OPENED_MESSAGE_IDS)) } + @Test + fun `a setup the core rejects must not re-arm the manual entry at the rejected config`() { + mockContextAppStart(context, tmpDir) + val activePrefs = PostHogMemoryPreferences() + val rejectedPrefs = PostHogMemoryPreferences() + + PostHogAndroid.setup(context, PostHogAndroidConfig(API_KEY).apply { cachePreferences = activePrefs }) + // The core no-ops this one — an instance is already active — so the shared client keeps + // sending under the first config and this one must not decide the gate or the prefs file. + PostHogAndroid.setup( + context, + PostHogAndroidConfig(API_KEY_2).apply { + cachePreferences = rejectedPrefs + capturePushNotificationOpened = false + }, + ) + + PostHogAndroid.capturePushNotificationOpened(Intent().putExtra("google.message_id", "probe")) + + assertEquals("probe", activePrefs.getValue(PUSH_OPENED_MESSAGE_IDS)) + assertNull(rejectedPrefs.getValue(PUSH_OPENED_MESSAGE_IDS)) + } + @Test fun `with after setup must not redirect the manual entry to the secondary project`() { mockContextAppStart(context, tmpDir)