Skip to content

fix(android): replay a notification tap that lands before setup - #579

Open
turnipdabeets wants to merge 3 commits into
mainfrom
fix/replay-pre-setup-push-intent
Open

fix(android): replay a notification tap that lands before setup#579
turnipdabeets wants to merge 3 commits into
mainfrom
fix/replay-pre-setup-push-intent

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

On Android, $push_notification_opened for a tap that arrives before Posthog().setup() runs is captured today only by accident — it depends on firebase_messaging doing us a favour.

The scenario (measured on a device):

  1. Launch the app, press HOME.
  2. Send an FCM tray notification.
  3. am kill the process — the task stays in recents.
  4. Tap the notification.

With com.posthog.posthog.AUTO_INIT disabled (what our own example ships), the plugin's PluginRegistry.NewIntentListener fires before Dart reaches Posthog().setup(), so PostHogAndroid.capturePushNotificationOpened(intent) no-ops — the SDK isn't set up yet. Android does not put that intent on Activity.getIntent() either, so the later read at setup finds the stale launch intent.

The event still lands today only because firebase_messaging's own NewIntentListener runs first and calls mainActivity.setIntent(intent) (FlutterFirebaseMessagingPlugin.handleNotificationIntent), which makes our later activity?.intent read find the push intent. Suppressing that one setIntent call drops the captured count to 0.

So any app whose FCM layer doesn't refresh the intent silently loses the tap: a hand-rolled FirebaseMessagingService, another push wrapper, or firebase_messaging itself when the message isn't in its store.

The fix: the listener remembers the tap, and setup prefers it over activity?.intent. Only an intent carrying google.message_id is kept, at most one, cleared on consume and on activity detach. The listener still calls PostHogAndroid.capturePushNotificationOpened(intent) exactly as before, so the AUTO_INIT-enabled path is untouched — and PostHogAndroid dedupes by google.message_id, so the two calls can't double-count.

Related:

💚 How did you test it?

Kotlin unit tests (PosthogFlutterPluginTest, all 42 green via ./gradlew :posthog_flutter:testDebugUnitTest): a tray tap is remembered, a non-push intent isn't, a second tap supersedes the first, an intent whose extras throw BadParcelableException is skipped without throwing, the replay consumes the remembered tap once, and detach drops it.

Also green: flutter test (351), dart analyze, dart format, ktlint, and the Dart public API snapshot.

On an emulator (API 34, google_apis), Flutter example app + real firebase_messaging, a real tray tap via uiautomator, counting $push_notification_opened in the /batch payloads posted to a local mock server. setIntent was suppressed with a temporary, uncommitted override in the example's MainActivity.

firebase setIntent AUTO_INIT before after
real disabled 1 1
real enabled 1 1
suppressed disabled 0 1
suppressed enabled 1 1

No double counting anywhere: each run logged exactly one Queued Event $push_notification_opened. Also checked on the fixed build — an ordinary cold start (app not running, tap launches it): 1; a warm tap (process alive, onNewIntent): 1.

Falsification: with only the replay removed (pendingPushIntent ?: activity?.intentactivity?.intent, listener still remembering, detach still clearing), the third row goes back to 0.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written with Claude Code. The bug was already localised in a prior investigation; this PR implements the agreed fix and re-confirms it on hardware.

Decisions along the way:

  • Remember the intent rather than call setIntent ourselves. Writing to the host Activity's intent is a side effect other plugins observe, and it would make us the second plugin racing over the same field. Keeping our own reference changes nothing outside the plugin.
  • Only intents carrying google.message_id are kept, so we never hold an arbitrary intent, and at most one — an Intent retains its extras, not the Activity.
  • Cleared on detach, so a tap can't be replayed into a later activity. The cost is a lost capture if the activity is destroyed for a config change in the sub-second window between the tap and setup(); standard Flutter activities declare configChanges for orientation, so that path is rare, and losing an event is the safer failure direction than mis-attributing one.
  • Reading the extra is wrapped in try/catch — unmarshalling the Bundle throws BadParcelableException when it carries a class this app can't load, which the native SDK already guards against on its own path.

Related PRs

One push-open capture effort across the mobile SDKs: count every notification tap exactly once, and stop losing taps the SDK starts too late to see.

PR What it does Blocked by
PostHog/posthog-android#783 Core: capture each PostHog push open once, whichever path reports it (ships as 3.65.0)
PostHog/posthog-ios#828 Same rule on iOS, so both platforms behave identically (ships as 3.75.0)
PostHog/posthog-js#4921 React Native on iOS: capture a tap that launches the app
PostHog/posthog-js#4929 React Native on Android: capture a tap lost when the process was killed but the task stayed in recents
#579 (this PR) Flutter: replay a tap that arrives before the SDK is set up, instead of relying on firebase_messaging
PostHog/posthog-js#4919 React Native plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
#578 Flutter plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
PostHog/posthog.com#20114 Docs corrections that are wrong today, independent of any release
PostHog/posthog.com#20102 Docs for the release-dependent behavior changes the SDK releases above

Merge order: posthog-android#783 and posthog-ios#828 first, then their releases. #4921, #4929 and #579 are independent and can go any time. #4919 and #578 go green once posthog-android 3.65.0 is published. Docs: #20114 can go now; #20102 last, after the releases.

Earlier work this builds on: PostHog/posthog-android#753, PostHog/posthog-ios#792, PostHog/posthog-js#4858, #556, #557, PostHog/posthog.com#19905.

A tray tap delivered to Activity.onNewIntent before Dart reaches
Posthog().setup() is dropped by PostHogAndroid, because the SDK is not
set up yet. Android does not put that intent on Activity.getIntent()
either, so the later read at setup finds the stale launch intent.

The event survives today only because firebase_messaging's own
NewIntentListener calls mainActivity.setIntent(intent) first. Any app
whose FCM layer does not do that loses the tap.

Remember the tap in the listener and prefer it at setup. Only an intent
carrying google.message_id is kept, at most one, cleared on consume and
on activity detach. PostHogAndroid dedupes by google.message_id, so the
AUTO_INIT path — where the listener already captures — cannot
double-count.
@turnipdabeets turnipdabeets self-assigned this Sep 12, 2026
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-09-12 19:56:05 UTC
Duration: 96772ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 140ms
Format Validation.Event Has Uuid 117ms
Format Validation.Event Has Lib Properties 115ms
Format Validation.Distinct Id Is String 114ms
Format Validation.Token Is Present 114ms
Format Validation.Custom Properties Preserved 115ms
Format Validation.Event Has Timestamp 115ms
Retry Behavior.Retries On 503 5328ms
Retry Behavior.Does Not Retry On 400 2117ms
Retry Behavior.Does Not Retry On 401 2117ms
Retry Behavior.Respects Retry After Header 8125ms
Retry Behavior.Implements Backoff 15442ms
Retry Behavior.Retries On 500 5225ms
Retry Behavior.Retries On 502 5220ms
Retry Behavior.Retries On 504 5223ms
Retry Behavior.Max Retries Respected 15431ms
Deduplication.Generates Unique Uuids 124ms
Deduplication.Preserves Uuid On Retry 5222ms
Deduplication.Preserves Uuid And Timestamp On Retry 10332ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5229ms
Deduplication.No Duplicate Events In Batch 124ms
Deduplication.Different Events Have Different Uuids 116ms
Compression.Sends Gzip When Enabled 115ms
Batch Format.Uses Proper Batch Structure 113ms
Batch Format.Flush With No Events Sends Nothing 108ms
Batch Format.Multiple Events Batched Together 122ms
Error Handling.Does Not Retry On 403 2115ms
Error Handling.Does Not Retry On 413 2116ms
Error Handling.Retries On 408 5223ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 13ms
Request Payload.Flags Request Uses V2 Query Param 10ms
Request Payload.Flags Request Hits Flags Path Not Decide 10ms
Request Payload.Flags Request Omits Authorization Header 9ms
Request Payload.Token In Flags Body Matches Init 10ms
Request Payload.Groups Round Trip 10ms
Request Payload.Groups Default To Empty Object 9ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 10ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 10ms
Request Payload.Disable Geoip Omitted Defaults To False 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 10ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 112ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 16ms
Request Lifecycle.Mock Response Value Is Returned To Caller 9ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 115ms

@greptile-apps

greptile-apps Bot commented Sep 12, 2026

Copy link
Copy Markdown
Prompt To Fix All With AI
### Issue 1
posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt:505-507
**Replay target is untested**

This test only verifies that `pendingPushIntent` is cleared. It does not verify that the remembered intent, rather than the stale Activity intent, reaches native capture. The core regression would therefore pass if the implementation cleared the pending intent without replaying it. Add a capture seam and assert both pending-intent precedence and the Activity-intent fallback.

### Issue 2
posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt:856
**Fatal errors are suppressed**

Catching `Throwable` also suppresses fatal errors such as `OutOfMemoryError` and `LinkageError`, although the documented malformed-extras case only requires handling `BadParcelableException`. Catch that expected exception so unrecoverable failures are not misreported as unreadable notification extras.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(android): replay a notification tap ..." | Re-trigger Greptile

Comment on lines +505 to +507
plugin.capturePushNotificationOpenedFromLaunchIntent()

assertNull(plugin.pendingPushIntent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Replay target is untested

This test only verifies that pendingPushIntent is cleared. It does not verify that the remembered intent, rather than the stale Activity intent, reaches native capture. The core regression would therefore pass if the implementation cleared the pending intent without replaying it. Add a capture seam and assert both pending-intent precedence and the Activity-intent fallback.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt
Line: 505-507

Comment:
**Replay target is untested**

This test only verifies that `pendingPushIntent` is cleared. It does not verify that the remembered intent, rather than the stale Activity intent, reaches native capture. The core regression would therefore pass if the implementation cleared the pending intent without replaying it. Add a capture seam and assert both pending-intent precedence and the Activity-intent fallback.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

if (intent?.getStringExtra(GOOGLE_MESSAGE_ID) != null) {
pendingPushIntent = intent
}
} catch (e: Throwable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fatal errors are suppressed

Catching Throwable also suppresses fatal errors such as OutOfMemoryError and LinkageError, although the documented malformed-extras case only requires handling BadParcelableException. Catch that expected exception so unrecoverable failures are not misreported as unreadable notification extras.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt
Line: 856

Comment:
**Fatal errors are suppressed**

Catching `Throwable` also suppresses fatal errors such as `OutOfMemoryError` and `LinkageError`, although the documented malformed-extras case only requires handling `BadParcelableException`. Catch that expected exception so unrecoverable failures are not misreported as unreadable notification extras.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

The clear-on-detach comment claimed a second engine could inherit the tap;
pendingPushIntent is a plugin instance field, so it cannot. Name the real
cost instead: a configuration change between the tap and setup() loses the
event. Also record that the tray-tap filter guards the activity-intent
fallback, not just memory, and move the FCM root cause out of the changeset.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant