diff --git a/.changeset/replay-pre-setup-push-intent.md b/.changeset/replay-pre-setup-push-intent.md new file mode 100644 index 00000000..6f87d00d --- /dev/null +++ b/.changeset/replay-pre-setup-push-intent.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': patch +--- + +Fix `$push_notification_opened` being lost on Android when a notification tap reaches the app before `Posthog().setup()` runs. 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 5c063c3a..f03ac942 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 @@ -49,6 +49,9 @@ import kotlin.math.roundToInt private const val FLUTTER_VIEW_CLASS_PREFIX = "io.flutter" private const val OCCLUSION_TICK_MS = 1000L +// The extra posthog-android reads a tray tap from, and dedupes it by. +private const val GOOGLE_MESSAGE_ID = "google.message_id" + // Ticks a not-occluded read must repeat before it ends an episode. Both the // active and the inactive path debounce, and they have to agree. private const val END_DEBOUNCE_TICKS = 1 @@ -797,6 +800,21 @@ class PosthogFlutterPlugin : capturePushNotificationOpenedFromLaunchIntent() } + /** + * The last tray tap seen by [newIntentListener], kept so setup can replay it. A tap that lands + * before `Posthog().setup()` is dropped by `PostHogAndroid` (the SDK isn't set up yet) and does + * not stay on `Activity.getIntent()` — Android only updates that if someone calls `setIntent`, + * which is `firebase_messaging`'s doing, not the framework's. Without this the event survives + * only by that accident, and any other FCM layer loses it. + */ + @VisibleForTesting + internal var pendingPushIntent: Intent? = null + + // Indirection so tests can observe the call: the SDK entry point lives on a companion object, + // which Mockito cannot stand in for. + @VisibleForTesting + internal var capturePushNotificationOpened: (Intent?) -> Unit = { PostHogAndroid.capturePushNotificationOpened(it) } + /** * 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` @@ -808,9 +826,15 @@ class PosthogFlutterPlugin : * 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. + * + * A tap remembered by [newIntentListener] wins over the Activity's intent: it is the tap the user + * actually made, while `getIntent()` may still hold the stale launch intent. */ - private fun capturePushNotificationOpenedFromLaunchIntent() { - PostHogAndroid.capturePushNotificationOpened(activity?.intent) + @VisibleForTesting + internal fun capturePushNotificationOpenedFromLaunchIntent() { + val intent = pendingPushIntent ?: activity?.intent + pendingPushIntent = null + capturePushNotificationOpened(intent) } /** @@ -820,10 +844,27 @@ class PosthogFlutterPlugin : */ private val newIntentListener = PluginRegistry.NewIntentListener { intent -> - PostHogAndroid.capturePushNotificationOpened(intent) + rememberPushIntent(intent) + capturePushNotificationOpened(intent) false } + /** + * Only a tray tap is worth remembering: anything else kept here would shadow the + * `activity?.intent` fallback at setup, on top of being a reference held for nothing. + */ + private fun rememberPushIntent(intent: Intent?) { + try { + if (intent?.getStringExtra(GOOGLE_MESSAGE_ID) != null) { + pendingPushIntent = intent + } + } catch (e: Throwable) { + // Reading an extra unmarshals the whole Bundle, which throws + // BadParcelableException for a class this app cannot load. + Log.w("PostHog", "Failed to read push notification intent: $e") + } + } + override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity application = binding.activity.application @@ -862,6 +903,10 @@ class PosthogFlutterPlugin : private fun removeNewIntentListener() { activityBinding?.removeOnNewIntentListener(newIntentListener) activityBinding = null + // The tap belonged to the Activity going away; replaying it into the next one would + // attribute it to a launch the user never made. A configuration change between the tap and + // setup() therefore loses the event — a miss beats a wrong attribution. + pendingPushIntent = null } // Idempotent: registering the same callbacks twice makes them fire twice. diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index b8f6e435..df427712 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -2,12 +2,15 @@ package com.posthog.flutter import android.app.Activity import android.content.Context +import android.content.Intent +import android.os.BadParcelableException import com.google.firebase.FirebaseApp import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry import io.flutter.plugin.common.StandardMethodCodec import org.mockito.ArgumentCaptor import org.mockito.Mockito @@ -18,6 +21,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue /* @@ -448,6 +452,130 @@ internal class PosthogFlutterPluginTest { Mockito.verify(mockResult).success(null) } + @Test + fun onNewIntent_trayTap_isCapturedAndRememberedForSetupToReplay() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin) + val captured = recordCaptures(plugin) + val intent = trayIntent("m1") + + assertFalse(listener.onNewIntent(intent)) + assertEquals(listOf(intent), captured) + assertSame(intent, plugin.pendingPushIntent) + } + + @Test + fun onNewIntent_withoutMessageId_isNotRemembered() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin) + + assertFalse(listener.onNewIntent(Mockito.mock(Intent::class.java))) + assertNull(plugin.pendingPushIntent) + } + + @Test + fun onNewIntent_unreadableExtras_isNotRememberedAndDoesNotThrow() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin) + val intent = Mockito.mock(Intent::class.java) + Mockito + .`when`(intent.getStringExtra("google.message_id")) + .thenThrow(BadParcelableException("unknown extra class")) + + assertFalse(listener.onNewIntent(intent)) + assertNull(plugin.pendingPushIntent) + } + + @Test + fun onNewIntent_secondTrayTap_supersedesTheFirst() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin) + val second = trayIntent("m2") + + listener.onNewIntent(trayIntent("m1")) + listener.onNewIntent(second) + + assertSame(second, plugin.pendingPushIntent) + } + + @Test + fun launchIntentReplay_capturesTheRememberedTapOverTheActivityIntent() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin, activityWithIntent(trayIntent("stale-launch"))) + val captured = recordCaptures(plugin) + val tap = trayIntent("m1") + listener.onNewIntent(tap) + + plugin.capturePushNotificationOpenedFromLaunchIntent() + + assertSame(tap, captured.last()) + assertNull(plugin.pendingPushIntent) + } + + @Test + fun launchIntentReplay_withoutRememberedTap_capturesTheActivityIntent() { + val plugin = PosthogFlutterPlugin() + val launch = trayIntent("launch") + attachActivity(plugin, activityWithIntent(launch)) + val captured = recordCaptures(plugin) + + plugin.capturePushNotificationOpenedFromLaunchIntent() + + assertEquals(listOf(launch), captured) + } + + @Test + fun launchIntentReplay_replaysTheRememberedTapOnlyOnce() { + val plugin = PosthogFlutterPlugin() + val launch = trayIntent("launch") + val listener = attachActivity(plugin, activityWithIntent(launch)) + val captured = recordCaptures(plugin) + listener.onNewIntent(trayIntent("m1")) + + plugin.capturePushNotificationOpenedFromLaunchIntent() + plugin.capturePushNotificationOpenedFromLaunchIntent() + + assertSame(launch, captured.last()) + } + + @Test + fun onDetachedFromActivity_dropsTheRememberedTap() { + val plugin = PosthogFlutterPlugin() + val listener = attachActivity(plugin) + listener.onNewIntent(trayIntent("m1")) + + plugin.onDetachedFromActivity() + + assertNull(plugin.pendingPushIntent) + } + + private fun trayIntent(messageId: String): Intent = + Mockito.mock(Intent::class.java).also { + Mockito.`when`(it.getStringExtra("google.message_id")).thenReturn(messageId) + } + + private fun activityWithIntent(intent: Intent): Activity = + Mockito.mock(Activity::class.java).also { + Mockito.`when`(it.intent).thenReturn(intent) + } + + private fun recordCaptures(plugin: PosthogFlutterPlugin): List = + mutableListOf().also { captured -> + plugin.capturePushNotificationOpened = { captured += it } + } + + private fun attachActivity( + plugin: PosthogFlutterPlugin, + activity: Activity = Mockito.mock(Activity::class.java), + ): PluginRegistry.NewIntentListener { + val binding = Mockito.mock(ActivityPluginBinding::class.java) + Mockito.`when`(binding.activity).thenReturn(activity) + plugin.onAttachedToActivity(binding) + val captor = ArgumentCaptor.forClass(PluginRegistry.NewIntentListener::class.java) + Mockito.verify(binding).addOnNewIntentListener(captor.capture()) + return captor.value + } + // The stubbed test Looper makes runOnMainThread run inline (myLooper and // getMainLooper both default to null), so mint round trips are synchronous here.