Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .changeset/quirky-melons-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog-android": minor
"posthog": minor
---

Add push notification support for PostHog Workflows. Register device push tokens with `registerPushNotificationToken(...)` (auto-registered from Firebase Cloud Messaging when `firebase-messaging` is on the classpath), and capture opens with `capturePushNotificationOpened(...)` (cold-start tray taps are auto-detected). Both are on by default and can be turned off with the new `capturePushNotificationSubscriptions` and `capturePushNotificationOpened` config flags. On `reset()` the token is unregistered for the logged-out user and re-registered under the new anonymous id, and `unregisterPushNotificationToken()` lets you unregister manually.
3 changes: 3 additions & 0 deletions buildSrc/src/main/java/PosthogBuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ object PosthogBuildConfig {
val ANDROIDX_CORE = "1.5.0"
val ANDROIDX_COMPOSE = "1.0.0"

// matches firebase-bom 34.16.0
val FIREBASE_MESSAGING = "25.1.1"

// Provides ComponentDialog (LifecycleOwner + OnBackPressedDispatcher) for the surveys-compose UI.
val ANDROIDX_ACTIVITY = "1.8.2"

Expand Down
8 changes: 7 additions & 1 deletion posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ public class com/posthog/android/PostHogAndroidConfig : com/posthog/PostHogConfi
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZ)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZ)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;Z)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZ)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCaptureApplicationLifecycleEvents ()Z
public final fun getCaptureDeepLinks ()Z
public final fun getCapturePushNotificationOpened ()Z
public final fun getCapturePushNotificationSubscriptions ()Z
public final fun getCaptureScreenViews ()Z
public final fun getSessionReplayConfig ()Lcom/posthog/android/replay/PostHogSessionReplayConfig;
public final fun setCaptureApplicationLifecycleEvents (Z)V
public final fun setCaptureDeepLinks (Z)V
public final fun setCapturePushNotificationOpened (Z)V
public final fun setCapturePushNotificationSubscriptions (Z)V
public final fun setCaptureScreenViews (Z)V
public final fun setSessionReplayConfig (Lcom/posthog/android/replay/PostHogSessionReplayConfig;)V
}
Expand Down
9 changes: 9 additions & 0 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ animalsniffer {
defaultTargets = emptySet()
}

configurations.configureEach {
// empty artifact since Kotlin 1.9 (merged into kotlin-stdlib); its alignment
// constraint resolves unstably under dependency locking
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-common")
}

dependencies {
// runtime
api(project(mapOf("path" to ":posthog")))
Expand All @@ -102,6 +108,7 @@ dependencies {

// compile only
compileOnly("androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}")
compileOnly("com.google.firebase:firebase-messaging:${PosthogBuildConfig.Dependencies.FIREBASE_MESSAGING}")

// compatibility
signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature")
Expand All @@ -114,6 +121,8 @@ dependencies {

// tests
testImplementation(testFixtures(project(":posthog")))
// exercises the Firebase-present token fetch path via mockStatic
testImplementation("com.google.firebase:firebase-messaging:${PosthogBuildConfig.Dependencies.FIREBASE_MESSAGING}")
testImplementation("org.mockito.kotlin:mockito-kotlin:${PosthogBuildConfig.Dependencies.MOCKITO}")
testImplementation("org.mockito:mockito-inline:${PosthogBuildConfig.Dependencies.MOCKITO_INLINE}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}")
Expand Down
12 changes: 11 additions & 1 deletion posthog-android/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,14 @@
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable

##---------------End: proguard configuration for okhttp3 ----------
##---------------End: proguard configuration for okhttp3 ----------

##---------------Begin: proguard configuration for Firebase Messaging (compileOnly) ----------
-dontwarn com.google.firebase.messaging.**
-dontwarn com.google.firebase.FirebaseApp
-dontwarn com.google.firebase.FirebaseOptions
-dontwarn com.google.android.gms.tasks.**

# used in reflection to check if Firebase Messaging is available at runtime
-keepnames class com.google.firebase.messaging.FirebaseMessaging
##---------------End: proguard configuration for Firebase Messaging (compileOnly) ----------
81 changes: 69 additions & 12 deletions posthog-android/gradle.lockfile

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.posthog.android.internal.PostHogAndroidNetworkStatusIntegration
import com.posthog.android.internal.PostHogAppInstallIntegration
import com.posthog.android.internal.PostHogLifecycleObserverIntegration
import com.posthog.android.internal.PostHogMetaPropertiesApplier
import com.posthog.android.internal.PostHogPushSubscriptionIntegration
import com.posthog.android.internal.PostHogSharedPreferences
import com.posthog.android.internal.PostHogTouchActivityIntegration
import com.posthog.android.internal.appContext
Expand Down Expand Up @@ -161,7 +162,9 @@ public class PostHogAndroid private constructor() {
config.addIntegration(PostHogTouchActivityIntegration(config))
config.addIntegration(PostHogLogCatIntegration(config))
if (context is Application) {
if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay) {
if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay ||
config.capturePushNotificationOpened
) {
config.addIntegration(
PostHogActivityLifecycleCallbackIntegration(
context,
Expand All @@ -177,6 +180,9 @@ public class PostHogAndroid private constructor() {
if (config.surveys) {
config.addIntegration(PostHogSurveysIntegration(context, config))
}
if (config.capturePushNotificationSubscriptions) {
config.addIntegration(PostHogPushSubscriptionIntegration(config))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import com.posthog.internal.PostHogQueue
* the SDK. To opt out of `$screen_name` stamping entirely, set this to `false` and avoid calling
* `PostHog.screen(...)` manually. Default: `true`.
* @property sessionReplayConfig Android-specific session replay capture options.
* @property capturePushNotificationSubscriptions Whether to auto-register this device's push
* token at startup. When enabled and Firebase Messaging is on the classpath, the SDK fetches the
* FCM token + Firebase `project_id` and registers them so PostHog Workflows can deliver push
* notifications. No effect when Firebase is absent (a single debug log). Instant token refresh
* 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`.
*/
public open class PostHogAndroidConfig
@JvmOverloads
Expand All @@ -31,6 +40,9 @@ public open class PostHogAndroidConfig
public var captureDeepLinks: Boolean = true,
public var captureScreenViews: Boolean = true,
public var sessionReplayConfig: PostHogSessionReplayConfig = PostHogSessionReplayConfig(),
// appended after sessionReplayConfig so @JvmOverloads keeps the pre-existing overloads
public var capturePushNotificationSubscriptions: Boolean = true,
public var capturePushNotificationOpened: Boolean = true,
) : PostHogConfig(
apiKey = apiKey,
host = host,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,24 @@ internal class PostHogActivityLifecycleCallbackIntegration(
private var postHog: PostHogInterface? = null
private var ownsInstallation = false

@Volatile
private var lastHandledPushMessageId: String? = null

private companion object {
private val integrationInstalled = AtomicBoolean(false)

private const val GOOGLE_MESSAGE_ID = "google.message_id"
}

override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?,
) {
// A non-null savedInstanceState redelivers the original launch intent (config-change recreation
// or process-kill restore). The in-memory id guard resets with the process, so gate on a fresh launch.
if (config.capturePushNotificationOpened && savedInstanceState == null) {
capturePushNotificationOpenedIfNeeded(activity)
}
if (config.captureDeepLinks) {
activity.intent?.let { intent ->
val props = mutableMapOf<String, Any>()
Expand Down Expand Up @@ -56,6 +66,47 @@ 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,
)
Comment thread
ioannisj marked this conversation as resolved.
} 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
}

override fun onActivityStarted(activity: Activity) {
if (config.captureScreenViews) {
val screenName = activity.activityLabelOrName(config)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.posthog.android.internal

import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import com.posthog.PostHogIntegration
import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroidConfig
import com.posthog.internal.PostHogThreadFactory
import com.posthog.internal.executeSafely
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean

/**
* Auto-registers this device's push token at startup so PostHog Workflows can deliver push
* notifications to it.
*
* Firebase Messaging is a `compileOnly` dependency — the SDK never ships its own messaging
* service and never hard-depends on Firebase. When Firebase is absent, [PushTokenFetcher] no-ops
* with a debug log. Instant token refresh still requires the host app to forward Firebase's
* `onNewToken` to [PostHogInterface.registerPushNotificationToken]; the startup fetch here only
* covers tokens refreshed since the last launch.
*/
internal class PostHogPushSubscriptionIntegration(
private val config: PostHogAndroidConfig,
private val tokenFetcher: PushTokenFetcher = FirebasePushTokenFetcher(config),
// Mirrors PostHogReplayIntegration's injectable executor: install() runs on setup()'s caller
// thread (typically main, inside Application.onCreate()), and FirebaseMessaging's first-touch
// init is synchronous, so the fetch is hopped off-thread. Tests inject a synchronous executor.
private val executor: ExecutorService = Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogPushSub")),
) : PostHogIntegration {
private var postHog: PostHogInterface? = null
private var ownsInstallation = false

private companion object {
private val integrationInstalled = AtomicBoolean(false)
}

@Synchronized
override fun install(postHog: PostHogInterface) {
if (!integrationInstalled.compareAndSet(false, true)) {
return
}
ownsInstallation = true
this.postHog = postHog
executor.executeSafely {
tokenFetcher.fetchToken { token, appId ->
this.postHog?.registerPushNotificationToken(token, appId)
}
}
}
Comment thread
ioannisj marked this conversation as resolved.

@Synchronized
override fun uninstall() {
if (!ownsInstallation) {
return
}
try {
postHog = null
} finally {
ownsInstallation = false
integrationInstalled.set(false)
}
}
Comment thread
ioannisj marked this conversation as resolved.
}

/**
* Resolves this device's push token and app identifier. Kept behind an interface so the Firebase
* dependency stays isolated to [FirebasePushTokenFetcher] and can be faked in tests.
*/
internal fun interface PushTokenFetcher {
fun fetchToken(onToken: (token: String, appId: String) -> Unit)
}

private const val FIREBASE_MESSAGING_CLASS = "com.google.firebase.messaging.FirebaseMessaging"

/**
* Fetches the FCM token and Firebase `project_id` (used as the push `app_id`).
*
* Presence is checked reflectively first, so an app without Firebase on the classpath sees a
* single debug log and nothing else. All direct Firebase references are confined to
* [fetchFirebaseToken], which only runs once the class is confirmed present.
*/
internal class FirebasePushTokenFetcher(
private val config: PostHogAndroidConfig,
// test seam: firebase-messaging is on the unit-test classpath, so the absent-classpath
// path is exercised by probing for a class name that doesn't resolve
private val messagingClassName: String = FIREBASE_MESSAGING_CLASS,
) : PushTokenFetcher {
override fun fetchToken(onToken: (token: String, appId: String) -> Unit) {
try {
Class.forName(messagingClassName)
} catch (e: Throwable) {
config.logger.log("Firebase Messaging not found on the classpath, skipping push token registration.")
return
}
fetchFirebaseToken(onToken)
}

// firebase-messaging 25.1.0 deprecates getToken in favor of FID-based registration;
// PostHog delivers via FCM HTTP v1 registration tokens, so we stay on it until the
// backend supports FIDs.
@Suppress("DEPRECATION")
private fun fetchFirebaseToken(onToken: (token: String, appId: String) -> Unit) {
val projectId =
try {
FirebaseApp.getInstance().options.projectId
Comment thread
ioannisj marked this conversation as resolved.
} catch (e: IllegalStateException) {
config.logger.log("Firebase is not initialized, skipping push token registration: $e.")
return
} catch (e: Throwable) {
config.logger.log("Failed to read Firebase project id, skipping push token registration: $e.")
return
}

if (projectId.isNullOrBlank()) {
config.logger.log("Firebase project id is missing, skipping push token registration.")
return
}

try {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
Comment thread
ioannisj marked this conversation as resolved.
// The completion runs later, on Play Services' Tasks executor — outside this
// try/catch's stack frame, so an uncaught throw here would crash the host app.
try {
if (task.isSuccessful) {
val token = task.result
if (!token.isNullOrBlank()) {
onToken(token, projectId)
} else {
config.logger.log("Firebase returned a blank push token, skipping registration.")
}
} else {
config.logger.log("Failed to fetch Firebase push token: ${task.exception}.")
}
} catch (e: Throwable) {
config.logger.log("Failed to handle Firebase push token completion: $e.")
}
}
} catch (e: Throwable) {
config.logger.log("Failed to request Firebase push token: $e.")
}
Comment thread
ioannisj marked this conversation as resolved.
}
}
Loading
Loading