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..6d115713c 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 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 + * [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..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 @@ -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_OPENED_MESSAGE_IDS import java.util.concurrent.atomic.AtomicBoolean /** @@ -21,13 +24,116 @@ 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" + + /** 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" + + 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 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 !in persistedIds) { + rememberPushId(config, 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) { + rememberPushId(config, 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 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()) { + @Suppress("DEPRECATION") + map[key] = get(key) + } + return map + } } override fun onActivityCreated( @@ -66,45 +172,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..670db6b53 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_OPENED_MESSAGE_IDS 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_OPENED_MESSAGE_IDS)) + } + + @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_OPENED_MESSAGE_IDS)) + assertNull(secondaryPrefs.getValue(PUSH_OPENED_MESSAGE_IDS)) + } + @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..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 @@ -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_OPENED_MESSAGE_IDS 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,155 @@ 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_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 + 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-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) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ec32a74d9..464b75c52 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_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; @@ -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_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 7b5ecd004..d454b7906 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_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 @@ -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_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( + VERSION, + BUILD, + DEVICE_ID, + SESSION_REPLAY, + ERROR_TRACKING, + CAPTURE_PERFORMANCE, + SURVEYS, + PUSH_OPENED_MESSAGE_IDS, + ) // 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..32dc483f2 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_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" @@ -63,6 +66,7 @@ public interface PostHogPreferences { public val ALL_INTERNAL_KEYS: Set = setOf( + 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 new file mode 100644 index 000000000..910cd6fcf --- /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_OPENED_MESSAGE_IDS +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_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_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 6376d21bf..904e8308c 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_OPENED_MESSAGE_IDS 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_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_OPENED_MESSAGE_IDS)) + + sut.close() + } + @Test fun `reset session id when reset is called`() { val http = mockHttp() 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(