Skip to content
Merged
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/replay-pre-setup-push-intent.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand All @@ -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)
}

/**
Expand All @@ -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) {
Comment thread
turnipdabeets marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/*
Expand Down Expand Up @@ -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?>(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)
Comment thread
turnipdabeets marked this conversation as resolved.
Comment thread
turnipdabeets marked this conversation as resolved.
}

@Test
fun launchIntentReplay_withoutRememberedTap_capturesTheActivityIntent() {
val plugin = PosthogFlutterPlugin()
val launch = trayIntent("launch")
attachActivity(plugin, activityWithIntent(launch))
val captured = recordCaptures(plugin)

plugin.capturePushNotificationOpenedFromLaunchIntent()

assertEquals(listOf<Intent?>(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<Intent?> =
mutableListOf<Intent?>().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.

Expand Down