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
5 changes: 5 additions & 0 deletions .changeset/fix-android-cold-start-push-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog_flutter': minor
---

Capture `$push_notification_opened` on Android for every notification tap, both on a cold launch and while the app is already running. Remove any manual `capturePushNotificationOpened` call wired to `FirebaseMessaging.onMessageOpenedApp` or `getInitialMessage()` β€” that tap is now captured automatically and the manual call is not deduplicated against it. Requires posthog-android 3.62.0.
2 changes: 1 addition & 1 deletion posthog_flutter/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ android {
dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
testImplementation 'org.mockito:mockito-core:5.0.0'
implementation 'com.posthog:posthog-android:[3.61.0,4.0.0)'
implementation 'com.posthog:posthog-android:[3.62.0,4.0.0)'
}

testOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
Expand Down Expand Up @@ -67,6 +68,7 @@ class PosthogFlutterPlugin :

private lateinit var applicationContext: Context
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private var application: Application? = null

private var postHogConfig: PostHogAndroidConfig? = null
Expand Down Expand Up @@ -791,11 +793,42 @@ class PosthogFlutterPlugin :
PostHogAndroid.setup(applicationContext, config)
postHogConfig = config
cachedReplayIntegration = null
}
capturePushNotificationOpenedFromLaunchIntent()
}

/**
* The SDK reads a notification tap from the launch Activity's intent when that Activity is
* created, which is long before Dart reaches `Posthog().setup()` β€” by then `onCreate`, `onStart`
* and `onResume` have all run. The intent is still on the Activity, so hand it over once both the
* SDK and the Activity exist.
*
* Called from both ends because neither alone covers both configurations: `onAttachedToEngine`
* (which runs `initPlugin`) always precedes `onAttachedToActivity`, so the AUTO_INIT path has no
* Activity yet, while on the Dart path the Activity is attached long before setup runs. Whichever
* precondition is satisfied last does the work; `PostHogAndroid` dedupes by message id, so a
* double call cannot double-count.
*/
private fun capturePushNotificationOpenedFromLaunchIntent() {
PostHogAndroid.capturePushNotificationOpened(activity?.intent)
}

/**
* A tap that arrives while the process is alive is delivered to `Activity.onNewIntent`, which
* `ActivityLifecycleCallbacks` does not expose β€” so the native SDK cannot see it and this plugin
* is the only layer that can. Returning false leaves the intent for other listeners.
*/
private val newIntentListener =
PluginRegistry.NewIntentListener { intent ->
PostHogAndroid.capturePushNotificationOpened(intent)
false
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
application = binding.activity.application
activityBinding = binding
binding.addOnNewIntentListener(newIntentListener)
capturePushNotificationOpenedFromLaunchIntent()
// Only if the detector is already running; else the setup path registers
// it. Keeps a default-off feature from installing app-wide callbacks.
if (occlusionDetectorRunning) {
Expand All @@ -805,22 +838,31 @@ class PosthogFlutterPlugin :

override fun onDetachedFromActivityForConfigChanges() {
unregisterLifecycleTracking()
removeNewIntentListener()
activity = null
}

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
application = binding.activity.application
activityBinding = binding
binding.addOnNewIntentListener(newIntentListener)
if (occlusionDetectorRunning) {
registerLifecycleTracking()
}
}

override fun onDetachedFromActivity() {
unregisterLifecycleTracking()
removeNewIntentListener()
activity = null
}

private fun removeNewIntentListener() {
activityBinding?.removeOnNewIntentListener(newIntentListener)
activityBinding = null
}

// Idempotent: registering the same callbacks twice makes them fire twice.
private fun registerLifecycleTracking() {
val app = application ?: return
Expand Down
25 changes: 13 additions & 12 deletions posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -651,20 +651,21 @@ class Posthog {
/// notification.
///
/// Call this only for opens [PostHogConfig.capturePushNotificationOpened]
/// cannot see itself β€” local notifications on either platform, plus
/// warm-start and foreground taps on Android β€” or the tap is counted twice.
/// That doc has the full coverage matrix.
/// cannot see itself β€” local notifications on either platform, notifications
/// you display yourself from a foreground message, and push delivered outside
/// FCM on Android. That doc has the full coverage matrix.
///
/// Do not wire this to `FirebaseMessaging.onMessageOpenedApp` or
/// `getInitialMessage()`: the SDK already captures those taps, and this call is
/// not deduplicated against them, so the open would be counted twice.
///
/// ```dart
/// if (Platform.isAndroid) {
/// FirebaseMessaging.onMessageOpenedApp.listen((m) {
/// Posthog().capturePushNotificationOpened(
/// title: m.notification?.title,
/// body: m.notification?.body,
/// payload: m.data,
/// );
/// });
/// }
/// // A notification you built and displayed yourself.
/// Posthog().capturePushNotificationOpened(
/// title: 'Your order shipped',
/// body: 'Track it in the app',
/// payload: {'order_id': '1234'},
/// );
/// ```
///
/// The event is built natively, so [PostHogConfig.beforeSend] callbacks do
Expand Down
13 changes: 6 additions & 7 deletions posthog_flutter/lib/src/posthog_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,12 @@ class PostHogConfig {
/// Whether to automatically capture `$push_notification_opened` when a user
/// taps a PostHog-delivered notification.
///
/// Coverage differs per platform. iOS hooks the notification-response
/// delegate, so every tap on a **remote** notification is captured whatever
/// the app state; locally-scheduled notifications are ignored. Android only
/// reads the launch intent, so it sees cold starts alone. Call
/// [Posthog.capturePushNotificationOpened] for the opens this misses β€”
/// local notifications on either platform, plus foreground messages and
/// warm-start taps on Android.
/// Every tap on a **remote** notification is captured on both platforms,
/// whether it cold-launched the app or the app was already running.
/// Locally-scheduled notifications are ignored. On Android a tap is
/// recognised by the `google.message_id` extra Firebase puts on the intent,
/// so push delivered outside FCM is not seen. Call
/// [Posthog.capturePushNotificationOpened] for the opens this misses.
///
/// **Flutter web:** not supported. Defaults to `true`.
bool capturePushNotificationOpened = true;
Expand Down
Loading