diff --git a/.changeset/fix-android-cold-start-push-open.md b/.changeset/fix-android-cold-start-push-open.md new file mode 100644 index 00000000..7f41fc62 --- /dev/null +++ b/.changeset/fix-android-cold-start-push-open.md @@ -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. diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index ca79394b..fc8a638c 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -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 { diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index b71f8609..862fdcf3 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -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 @@ -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 @@ -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) { @@ -805,12 +838,15 @@ 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() } @@ -818,9 +854,15 @@ class PosthogFlutterPlugin : 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 diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 305a7815..ccb675d1 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -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 diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 195de8d4..528ef2cb 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -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;