Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/push-open-late-install.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<String> =
(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<String, Any?> {
val map = mutableMapOf<String, Any?>()
for (key in keySet()) {
@Suppress("DEPRECATION")
map[key] = get(key)
}
return map
}
}

override fun onActivityCreated(
Expand Down Expand Up @@ -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<String, Any?> {
val map = mutableMapOf<String, Any?>()
for (key in keySet()) {
@Suppress("DEPRECATION")
map[key] = get(key)
}
return map
capturePushNotificationOpened(intent, postHog, config)
}

override fun onActivityStarted(activity: Activity) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading