Skip to content

feat(push): capture a launch intent the SDK was installed too late to read - #753

Draft
turnipdabeets wants to merge 5 commits into
mainfrom
fix/push-open-late-install-clean
Draft

feat(push): capture a launch intent the SDK was installed too late to read#753
turnipdabeets wants to merge 5 commits into
mainfrom
fix/push-open-late-install-clean

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The Android half of PostHog/posthog-flutter#558$push_notification_opened is never captured in a Flutter app.

The tray intent is read in onActivityCreated, and install() only calls registerActivityLifecycleCallbacks — it never seeds from an Activity that already exists. A host that configures the SDK from its own runtime installs too late to see that callback. Measured in the Flutter 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. Native apps that call setup() from Application.onCreate are unaffected — this only bites hosts that initialise late, which today means Flutter and React Native.

Unlike iOS, no buffering is needed: the tap is durable state on the Intent, so the fix is to read it late rather than to hold it.

Changes

  • PostHogAndroid.capturePushNotificationOpened(intent) — one new public entry point for late-installing hosts. Shares the extraction and google.message_id dedupe with the automatic path, so calling both cannot double-count.
  • On that path only, recently opened ids are also persisted (pushOpenedMessageIds, a bounded history). A caller with no savedInstanceState cannot tell a process-kill restore — which hands the Activity back its original intent — from a real second tap. Read and write are both scoped to the new path, so a pure-native app's stored state is byte-identical to today.
  • onActivityCreated keeps savedInstanceState as its restore gate and never touches the persisted id: it is the strictly better signal, because it separates a restore from a genuine re-tap.

💚 How did you test it?

  • ./gradlew :posthog-android:testDebugUnitTest :posthog:test — BUILD SUCCESSFUL. spotlessCheck, :posthog:apiCheck, :posthog-android:apiCheck clean.
  • Ten new tests. Three were verified to fail with their fix reverted — the ones guarding the silent regressions below: the ALL_INTERNAL_KEYS entry, the automatic path still captures a genuine re-tap in a new process, and with after setup must not redirect the manual entry to the secondary project.
  • On device (Pixel 9 emulator, Flutter example app against a local build): cold start with a google.message_id extra → 1 capture (was 0); same id in a new process → 0; a new id → 1; no push extra → 0.

Testing

Beyond the unit tests, verified on a Pixel 9 emulator through two hosts:

posthog-android's own sample (setup() in Application.onCreate, plus the new onNewIntent forwarding this PR adds):

Scenario Result
Cold launch from a tap captured — also on unmodified origin/main, confirming no regression
Tap while running, forwarded from onNewIntent captured
Same message id again not captured
Fresh launch with a previously seen id captured — a real second tap, not a restore
Launch with no push payload not captured

A Flutter host (PostHog/posthog-flutter#557), where the SDK installs after the launch Activity has already resumed: cold and warm both captured, with the dedupe holding across process death.

Warm delivery needs android:launchMode="singleTop" — without it the system resumes the task instead of delivering the intent, which is why the sample now declares it.

📝 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

The changeset declares posthog as well as posthog-android — the core ALL_INTERNAL_KEYS entry is load-bearing (see below) and would not otherwise be released.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code (session), driven by @turnipdabeets. Root cause measured on an emulator before any code was written; a first attempt that hooked onActivityStarted/onActivityResumed was implemented, tested, and discarded once the ordering above was measured.

Six review rounds ran over this branch. Three findings are worth a reviewer's attention because each was a silent regression caught only by an executed test:

  • The new preferences key was initially missing from ALL_INTERNAL_KEYS, so it rode on every event as a super property. getAll() feeds buildProperties(); every other internal key is listed.
  • Persisting the dedupe id on the automatic path suppressed a genuine second tap of the same notification after a process death — demonstrated 2 → 1 captures. Hence the read/write scoping.
  • An earlier "mark after delivery" guard was dead code (?: return on a Unit function), and the two synchronized blocks it required reopened the race the lock existed to close — two callers both delivered for one id.

A warm-start tap arrives through Activity.onNewIntent, which ActivityLifecycleCallbacks does not expose — so a native host forwards it with one line, which the sample now demonstrates. PostHog/posthog-flutter#557 does it automatically for Flutter apps.

Consumer: PostHog/posthog-flutter#557 (which closes PostHog/posthog-flutter#558) raises its floor to [3.62.0,4.0.0) and is held until this releases.

🤖 Generated with Claude Code

https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd

… read

The tray intent is read in onActivityCreated, and install() only registers
lifecycle callbacks — it never seeds from an Activity that already exists.
A host that configures the SDK from its own runtime (Flutter and React
Native reach setup() from Dart/JS) installs after the launch Activity has
created, started and resumed, so no callback ever fires for it.

Adds PostHogAndroid.capturePushNotificationOpened(intent) for those hosts,
sharing the extraction and message-id dedupe with the automatic path. On
that path the id is also persisted, because a caller with no
savedInstanceState cannot tell a process-kill restore from a real re-tap.

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-android Compliance Report

Date: 2026-09-02 16:09:07 UTC
Duration: 118462ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 378ms
Format Validation.Event Has Uuid 33ms
Format Validation.Event Has Lib Properties 29ms
Format Validation.Distinct Id Is String 27ms
Format Validation.Token Is Present 29ms
Format Validation.Custom Properties Preserved 28ms
Format Validation.Event Has Timestamp 23ms
Retry Behavior.Retries On 503 7025ms
Retry Behavior.Does Not Retry On 400 4024ms
Retry Behavior.Does Not Retry On 401 4029ms
Retry Behavior.Respects Retry After Header 7028ms
Retry Behavior.Implements Backoff 17035ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7020ms
Retry Behavior.Retries On 504 7021ms
Retry Behavior.Max Retries Respected 17035ms
Deduplication.Generates Unique Uuids 43ms
Deduplication.Preserves Uuid On Retry 7018ms
Deduplication.Preserves Uuid And Timestamp On Retry 12025ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7018ms
Deduplication.No Duplicate Events In Batch 37ms
Deduplication.Different Events Have Different Uuids 23ms
Compression.Sends Gzip When Enabled 23ms
Batch Format.Uses Proper Batch Structure 18ms
Batch Format.Flush With No Events Sends Nothing 12ms
Batch Format.Multiple Events Batched Together 32ms
Error Handling.Does Not Retry On 403 4021ms
Error Handling.Does Not Retry On 413 4022ms
Error Handling.Retries On 408 5025ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 37ms
Request Payload.Flags Request Uses V2 Query Param 25ms
Request Payload.Flags Request Hits Flags Path Not Decide 24ms
Request Payload.Flags Request Omits Authorization Header 31ms
Request Payload.Token In Flags Body Matches Init 26ms
Request Payload.Groups Round Trip 39ms
Request Payload.Groups Default To Empty Object 25ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 24ms
Request Payload.Disable Geoip Omitted Defaults To False 23ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 27ms
Request Lifecycle.No Flags Request On Init Alone 14ms
Request Lifecycle.No Flags Request On Normal Capture 21ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 49ms
Request Lifecycle.Mock Response Value Is Returned To Caller 22ms
Retry Behavior.Retries Flags On 502 323ms
Retry Behavior.Retries Flags On 504 326ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 21ms

Reverting the except-list entry previously failed nothing, so a future
trim would have let a process-death restore re-capture the same tap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
turnipdabeets and others added 2 commits September 2, 2026 10:40
A host that also hands over warm-start intents writes to the same store
as the launch intent, and only the launch intent is redelivered after a
process death. With a single slot a warm tap displaced the launch id, so
a later restore captured the launch tap a second time.

Keeps a bounded history instead, and renames the key to match — it is
unreleased, so the rename costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
Android gives libraries no way to observe Activity.onNewIntent, so the
new API only helps if the host forwards it. The sample now does, and
declares singleTop — without it the system resumes the task instead of
delivering the intent, and the snippet would never fire.

Also corrects two KDoc sentences written for the single-id design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
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