Skip to content

fix(push): capture $push_notification_opened on Android cold and warm start - #557

Draft
turnipdabeets wants to merge 5 commits into
mainfrom
fix/push-open-android-cold-start
Draft

fix(push): capture $push_notification_opened on Android cold and warm start#557
turnipdabeets wants to merge 5 commits into
mainfrom
fix/push-open-android-cold-start

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Fixes #558$push_notification_opened is never captured on a cold launch from a notification tap. (#556 fixes the iOS half, #555; the two have separate native release gates, so they ship separately.)

The native SDK reads the tray intent in onActivityCreated, but plugin registration happens inside Activity.onCreate, after the framework has already dispatched that callback. Measured in the example app:

17:54:27.516  MainActivity.onCreate
17:54:27.939  onCreate END / onStart / onResume   ← all three complete
17:54:28.582  first PostHog event                 ← SDK installs ~640ms later

Every lifecycle callback for the launch Activity is missed. AUTO_INIT does not rescue it either — registration is still inside onCreate — which is where this differs from iOS, where AUTO_INIT does win the race.

⚠️ Blocked on PostHog/posthog-android#753 — needs posthog-android 3.62.0 for PostHogAndroid.capturePushNotificationOpened(intent). The floor here is already raised to [3.62.0,4.0.0), so merging before that releases breaks every user's Android build at dependency resolution. Draft until then.

Changes

  • Captures warm-start taps too. A tap that arrives while the app is running goes to Activity.onNewIntent, which ActivityLifecycleCallbacks does not expose — so the native SDK cannot see it and this plugin is the only layer that can. A NewIntentListener is registered on attach and re-attach, removed on both detach paths.
  • Obsoletes the previously documented manual wiring. The Dart docs no longer tell users to call capturePushNotificationOpened from FirebaseMessaging.onMessageOpenedApp / getInitialMessage(); that call is not deduplicated against the automatic capture, so keeping it now double-counts. Hence the minor bump.
  • Hands the Activity's launch intent to the native SDK once both the SDK and the Activity exist.
  • Called from both setupPostHog() and onAttachedToActivity, 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; the native message-id dedupe makes the double call safe.
  • Raises the posthog-android floor to [3.62.0,4.0.0).

💚 How did you test it?

On device (Pixel 9 emulator, example app against a local posthog-android build):

  • Cold start with a google.message_id extra → 1 capture (was 0).
  • Same id in a new process → 0 (persisted dedupe); a new id → 1; no push extra → 0.
  • Pure AUTO_INIT app (Dart setup() commented out) — 0 captures without the onAttachedToActivity hook, 1 with it. This is why the hook is called from both places; an earlier test that left the Dart setup() in place passed either way and masked the gap.

flutter analyze clean. The unit tests live in PostHog/posthog-android#753.

Testing

Verified on a Pixel 9 emulator against a local posthog-android build, with events confirmed in a real PostHog project (not just device logs):

Scenario Result
Cold launch from a tap captured
Tap while the app is running (warm) captured
Same message id again, warm not captured
Same message id in a new process not captured
Original cold id after a warm tap of a different id not captured
Launch with no push payload not captured
Tap, then two device rotations one event, not three
Pure AUTO_INIT app, no Dart setup() captured — 0 without the onAttachedToActivity hook, 1 with it

The AUTO_INIT row is why the hand-over is called from two places: an earlier test that left the Dart setup() in place passed either way and masked the gap.

📝 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)

Built with Claude Code (session), driven by @turnipdabeets. Nearly all the logic is in PostHog/posthog-android#753; this side is the trigger.

No unit test here: PostHogAndroid.capturePushNotificationOpened is a companion function without @JvmStatic, so mockStatic cannot intercept it and asserting the hand-over would need a new production test seam. The AUTO_INIT device run above is the honest verification.

Also closes the warm-start gap: a tap that arrives while the app is running goes to Activity.onNewIntent, which ActivityLifecycleCallbacks does not expose — so the native SDK cannot see it and this plugin is the only layer that can. A NewIntentListener is registered on attach and re-attach and removed on both detach paths.

⚠️ Behaviour change, hence minor: apps following the previously documented FirebaseMessaging.onMessageOpenedAppcapturePushNotificationOpened(...) pattern must remove that call. The plugin now captures that tap and the manual API is not deduplicated against it, so it would count twice. The changeset and the Dart docs both say so.

🤖 Generated with Claude Code

https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd

Plugin registration runs inside Activity.onCreate, but the native SDK reads
the tray intent in onActivityCreated — which the framework has already
dispatched by then. onStart and onResume have also run before Dart reaches
setup(), so no lifecycle callback for the launch Activity is ever observed.

Hands the Activity's intent to PostHogAndroid.capturePushNotificationOpened
from both setup() and onAttachedToActivity: neither hook alone covers both
configurations, since onAttachedToEngine precedes onAttachedToActivity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-09-02 15:52:18 UTC
Duration: 96742ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 135ms
Format Validation.Event Has Uuid 118ms
Format Validation.Event Has Lib Properties 116ms
Format Validation.Distinct Id Is String 115ms
Format Validation.Token Is Present 114ms
Format Validation.Custom Properties Preserved 116ms
Format Validation.Event Has Timestamp 116ms
Retry Behavior.Retries On 503 5330ms
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 15447ms
Retry Behavior.Retries On 500 5225ms
Retry Behavior.Retries On 502 5226ms
Retry Behavior.Retries On 504 5225ms
Retry Behavior.Max Retries Respected 15445ms
Deduplication.Generates Unique Uuids 123ms
Deduplication.Preserves Uuid On Retry 5224ms
Deduplication.Preserves Uuid And Timestamp On Retry 10333ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5231ms
Deduplication.No Duplicate Events In Batch 124ms
Deduplication.Different Events Have Different Uuids 117ms
Compression.Sends Gzip When Enabled 115ms
Batch Format.Uses Proper Batch Structure 114ms
Batch Format.Flush With No Events Sends Nothing 108ms
Batch Format.Multiple Events Batched Together 123ms
Error Handling.Does Not Retry On 403 2115ms
Error Handling.Does Not Retry On 413 2117ms
Error Handling.Retries On 408 5225ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 14ms
Request Payload.Flags Request Uses V2 Query Param 8ms
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 8ms
Request Payload.Groups Round Trip 9ms
Request Payload.Groups Default To Empty Object 8ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 8ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 9ms
Request Payload.Disable Geoip Omitted Defaults To False 8ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 8ms
Request Lifecycle.No Flags Request On Init Alone 3ms
Request Lifecycle.No Flags Request On Normal Capture 113ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller 9ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 115ms

turnipdabeets and others added 4 commits September 2, 2026 10:18
A tap on a live process is delivered to Activity.onNewIntent, which
ActivityLifecycleCallbacks does not expose — so posthog-android cannot
observe it and this plugin is the only layer that can.

Registers a NewIntentListener on attach and re-attach, removed on both
detach paths. The native message-id dedupe keeps a redelivered intent
from counting twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
ktlint requires lexicographic import order; the new import broke it.
The cold- and warm-start fixes ship together, so they read as one
changelog entry rather than two adjacent bullets about the same event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
The plugin now captures them, and the manual API is not deduplicated
against it — so an app following the documented onMessageOpenedApp
pattern would count every warm tap twice.

Bumped to minor: existing integrations must remove that call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
…Message

The snippet referenced an undeclared `notification` whose `payload` would
not type-check. getInitialMessage is the terminated-state companion to
onMessageOpenedApp and double-counts the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
@turnipdabeets turnipdabeets changed the title fix(push): capture $push_notification_opened on Android cold start fix($push_notification_opened): capture Android notification taps on cold and warm start Sep 2, 2026
@turnipdabeets turnipdabeets changed the title fix($push_notification_opened): capture Android notification taps on cold and warm start fix(push): capture $push_notification_opened on Android cold and warm start Sep 2, 2026
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.

Android: $push_notification_opened not captured on a notification tap

1 participant