Skip to content

fix(push): require posthog-android 3.64.0 for push-open dedupe - #578

Draft
turnipdabeets wants to merge 6 commits into
mainfrom
fix/push-open-manual-dedupe
Draft

fix(push): require posthog-android 3.64.0 for push-open dedupe#578
turnipdabeets wants to merge 6 commits into
mainfrom
fix/push-open-manual-dedupe

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Before PostHog/posthog.com#19905 (merged 2026-09-10), the Flutter push docs told apps to capture Android opens themselves:

if (Platform.isAndroid) {
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    Posthog().capturePushNotificationOpened(
      title: message.notification?.title,
      body: message.notification?.body,
      payload: message.data,
    );
  });
}

Since 5.40.0 (#557) the plugin captures every Android tap itself, so an app that kept this snippet sends two $push_notification_opened events per tap. The server's push-open-tracking.ts turns each one into a push_opened app metric with no dedupe, so Workflows open rates are inflated too.

Cold taps are affected as well as warm ones. On Android, firebase_messaging also emits onMessageOpenedApp for the tap that launched the app, because its onAttachedToActivity calls handleNotificationIntent(activity.getIntent()). So one cold-start tap is reported to both onMessageOpenedApp and getInitialMessage(): the old snippet alone gives 2 events on a cold tap, and 3 if the app also wired getInitialMessage().

This PR first fixed that inside the plugin. PostHog/posthog-android#783 now moves the dedupe into PostHog.capturePushNotificationOpened in posthog-android core, and PostHog/posthog-ios#828 applies the same rule in posthog-ios. It skips a repeat of a PostHog-sent notification (same posthog.invocation_id + action_id) captured within 5 minutes, whichever path reported it first. Every path in this plugin reaches that method: the launch-intent and NewIntentListener paths through PostHogAndroid.capturePushNotificationOpened(intent), and the manual method-channel call. A match doesn't consume the key, so the one automatic capture of a cold tap skips both manual calls. Keeping the plugin's own dedupe would stack two layers, so this PR is now just the version floors.

Changes

  • Removes the plugin-level dedupe: AutoCapturedPushOpens, captureAutomaticPushNotificationOpened, the skip and Log.w on the manual path, AutoCapturedPushOpensTest, and the org.json test dependency. PosthogFlutterPlugin.kt is back to main.
  • Raises the posthog-android floor to [3.65.0,4.0.0), the release planned for #783. The example's settings.gradle.kts hook doesn't pin a version, so nothing else changes.
  • Raises the posthog-ios floor to >= 3.75.0, < 4.0.0 in darwin/posthog_flutter.podspec and darwin/posthog_flutter/Package.swift, the release planned for feat(push): capture each PostHog push open once across automatic and manual paths posthog-ios#828. The old >= 3.74.0 floor let a fresh install resolve 3.75.0 but left a locked one on 3.74.x without the fix.
  • Rewrites the capturePushNotificationOpened Dart doc. It still says not to wire the call to onMessageOpenedApp / getInitialMessage(). On Android and iOS, a repeat of a PostHog-sent notification captured in the last 5 minutes is now skipped; for pushes not sent by PostHog the open is still counted twice.
  • Replaces the changeset (patch).

Behavior differences from the earlier plugin-level version of this PR:

  • The skip is no longer logged as a warning. Core only logs it when debug is on.
  • The skip also covers a manual call that arrives before the automatic capture, and repeated manual calls, because the check sits where every path ends.
  • A repeat more than 5 minutes after the first capture is counted. The window exists because a workflow loop or rerun can send a new notification under the same key (see #783).

CI fails until posthog-android 3.65.0 and posthog-ios 3.75.0 are published (3.64.0 shipped captureTouches, not this fix): Gradle can't resolve [3.65.0,4.0.0) and CocoaPods/SwiftPM can't resolve >= 3.75.0 yet. Merge this after both releases. It stays a draft until then.

Related: PostHog/posthog-android#783 (the core dedupe), PostHog/posthog-js#4919 (the React Native plugin's equivalent, being repurposed the same way), PostHog/posthog.com#19905 (the docs change), #557 (automatic Android capture).

💚 How did you test it?

Device run: fresh Pixel 6 AVD instance (Android 17, API 37) on its own port. This branch's example app was built against #783 at f7ae7aeb, published to mavenLocal as posthog-android:3.65.0-local-flutter / posthog:6.36.0-local-flutter and forced in through the gitignored example/android/local.settings.gradle.kts (dependencyInsight confirmed 3.65.0-local-flutter (forced)). The harness is the same one as before and was not committed:

  • firebase_core + firebase_messaging 16.6.0 with placeholder Firebase options, and the old docs snippet pasted in verbatim.
  • Each FCM message was injected with a root am broadcast com.google.android.c2dm.intent.RECEIVE, so the FCM SDK posts a real tray notification. The posthog entry was {"workflow_id":"wf-1","invocation_id":"inv-fl-1","action_id":"step-1"}.
  • Taps were real taps in the notification shade, with the app backgrounded (warm) or its process killed with am kill after the notification was posted (cold).
  • Counts are $push_notification_opened events in the SDK's /batch bodies, sent to a local mock server. App data was cleared before every row.
Scenario 5.40.1 + posthog-android 3.63.1 This PR + #783 core
Old snippet, warm tap, PostHog push 2 1
Old snippet, cold tap, PostHog push 2 1
Old snippet + getInitialMessage() handler, cold tap 3 1
Old snippet, warm tap, push with no posthog entry 2 2 (manual call still captured)

The "before" column is the measurement from #783's device table. In every deduped row, the event that survives is the automatic one, carrying $notification_invocation_id and $notification_action_id. Logcat shows each manual call arriving (onMessageOpenedApp, and getInitialMessage in row 3), each followed by core's Skipped $push_notification_opened: notification inv-fl-1/step-1 was already captured.

Also ran:

  • flutter test: 348 passed.
  • dart format, dart analyze, ktlint and the Dart public API snapshot check: all clean. The public API doesn't change.
  • ./gradlew :posthog_flutter:testDebugUnitTest against the local 3.65.0 build: 34 passed.

Not verified:

  • Resolution against the real posthog-android 3.65.0 artifact, which isn't published yet.
  • A real FCM delivery from a PostHog Workflow. Messages were injected locally with the same posthog JSON shape the server sends.
  • iOS. posthog-ios 3.75.0 isn't published yet, so nothing resolved the new floor. The iOS change is the version bump and the doc comment.

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

No new tests here: the dedupe and its tests live in posthog-android#783.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code, driven by @turnipdabeets. The first two commits added a plugin-level dedupe matching PostHog/posthog-js#4919. The two implementations had already diverged (consume-once vs. not) and each one mirrored the SDK's skip gates, so the logic moved into posthog-android core in #783. The last commit removes the plugin copy and raises the floor.

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 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, posthog-ios 3.75.0
#578 (this PR) Flutter plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0, posthog-ios 3.75.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 and posthog-ios 3.75.0 are 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.

@turnipdabeets turnipdabeets self-assigned this Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-09-12 19:55:47 UTC
Duration: 96744ms

✅ 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 114ms
Format Validation.Distinct Id Is String 114ms
Format Validation.Token Is Present 113ms
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 8123ms
Retry Behavior.Implements Backoff 15437ms
Retry Behavior.Retries On 500 5224ms
Retry Behavior.Retries On 502 5225ms
Retry Behavior.Retries On 504 5223ms
Retry Behavior.Max Retries Respected 15441ms
Deduplication.Generates Unique Uuids 123ms
Deduplication.Preserves Uuid On Retry 5223ms
Deduplication.Preserves Uuid And Timestamp On Retry 10334ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5230ms
Deduplication.No Duplicate Events In Batch 123ms
Deduplication.Different Events Have Different Uuids 116ms
Compression.Sends Gzip When Enabled 115ms
Batch Format.Uses Proper Batch Structure 112ms
Batch Format.Flush With No Events Sends Nothing 108ms
Batch Format.Multiple Events Batched Together 121ms
Error Handling.Does Not Retry On 403 2116ms
Error Handling.Does Not Retry On 413 2117ms
Error Handling.Retries On 408 5224ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 12ms
Request Payload.Flags Request Uses V2 Query Param 9ms
Request Payload.Flags Request Hits Flags Path Not Decide 9ms
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 9ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 10ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 9ms
Request Payload.Disable Geoip Omitted Defaults To False 10ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 8ms
Request Lifecycle.No Flags Request On Init Alone 5ms
Request Lifecycle.No Flags Request On Normal Capture 111ms
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 114ms

@turnipdabeets
turnipdabeets marked this pull request as ready for review September 11, 2026 14:39
@turnipdabeets
turnipdabeets requested a review from a team as a code owner September 11, 2026 14:39
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Prompt To Fix All With AI
### Issue 1
posthog_flutter/android/src/test/kotlin/com/posthog/flutter/AutoCapturedPushOpensTest.kt:12-18
**Tests are not parameterised**

These tests repeat the same setup, remember, and membership checks across separate methods and inline input lists. This violates the repository directive to prefer parameterised tests and works against its OnceAndOnlyOnce rule. Please consolidate the matching, non-matching, and invalid-entry cases into parameterised tests before merging.

---

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

Reviews (1): Last reviewed commit: "fix(push): key the push-open dedupe on i..." | Re-trigger Greptile

… made

Before posthog.com#19905 the Flutter docs told apps to call
capturePushNotificationOpened from FirebaseMessaging.onMessageOpenedApp on
Android. Since 5.40.0 the plugin captures that tap itself, so an app that
kept the snippet sends two $push_notification_opened events per tap, and
the server turns each one into a push_opened metric with no dedupe.

The plugin now remembers the posthog.invocation_id of each tap it hands
to PostHogAndroid, and a manual call carrying a remembered id is skipped
with a warning. PostHogAndroid returns no result, so its no-op gates are
mirrored to avoid remembering a tap it did not capture. Payloads without
a posthog entry behave as before.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL
Every step of one workflow run shares the run's invocation_id, and
push-notification.service.ts stamps each push with its step's action_id.
Keyed on invocation_id alone, the automatic capture of one step's push
swallowed a legitimate manual capture of the next step's push.

Keys on "$invocationId/$actionId" (action_id optional), matching the
React Native plugin.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL
posthog-android#783 moves the dedupe into PostHog.capturePushNotificationOpened,
which every capture path reaches, including this plugin's launch-intent and
NewIntentListener paths and its manual method-channel call. A repeat of a
PostHog-sent notification (same invocation_id and action_id) within 5 minutes
is skipped there, so the plugin-level copy would only stack a second layer.

Removes AutoCapturedPushOpens, its tests and the org.json test dependency,
raises the posthog-android floor to 3.64.0, and rewrites the
capturePushNotificationOpened doc to match.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL
@turnipdabeets
turnipdabeets force-pushed the fix/push-open-manual-dedupe branch from 26b7faf to 47b9f13 Compare September 12, 2026 16:26
…onOpened

Say that only PostHog-sent notifications carry the ids the 5-minute window
matches on, and that the Android automatic capture has no notification text
a manual re-report could add. Lead the changeset with the fixed behavior.
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