Skip to content

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

Merged
turnipdabeets merged 7 commits into
mainfrom
fix/push-open-late-install-clean
Sep 8, 2026
Merged

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

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🔗 Related PRs

One fix, five PRs — three SDKs plus the docs. Each Flutter PR is gated on the native release it depends on.

PR What it does
PostHog/posthog-ios#792 native iOS — hold a tap delivered before setup()
#753 native Android — read a launch intent the SDK installed too late to see — ← this PR
PostHog/posthog-flutter#556 Flutter iOS cold start · needs posthog-ios 3.72.0 · fixes PostHog/posthog-flutter#555
PostHog/posthog-flutter#557 Flutter Android cold + warm start · needs posthog-android 3.62.0 · fixes PostHog/posthog-flutter#558
PostHog/posthog.com#19905 docs for all of the above

Order: PostHog/posthog-ios#792 and #753 merge and release first → PostHog/posthog-flutter#556 and PostHog/posthog-flutter#557 leave draft and go green on their own once the floors publish → PostHog/posthog.com#19905 last, since posthog.com deploys on merge.

💡 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:testReleaseUnitTest :posthog:test :posthog-android-surveys-compose:test — 945 core and 364 Android cases, 0 failures. 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.

Re-verified 2026-09-08, after rebasing on main (which has moved to 3.61.3, so this minor still lands on 3.62.0 and the Flutter floor is unchanged): full unit suites green, and the sample's cold and warm taps re-run on a Pixel 9 emulator.

Method note: the sample points at a local HTTP server standing in for the ingestion host (via the existing POSTHOG_HOST hook), so every assertion above is made against the exact /batch body the SDK sent, not a dashboard query.

📝 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

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-09-08 14:35:09 UTC
Duration: 118388ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 365ms
Format Validation.Event Has Uuid 39ms
Format Validation.Event Has Lib Properties 31ms
Format Validation.Distinct Id Is String 29ms
Format Validation.Token Is Present 30ms
Format Validation.Custom Properties Preserved 28ms
Format Validation.Event Has Timestamp 31ms
Retry Behavior.Retries On 503 7035ms
Retry Behavior.Does Not Retry On 400 4023ms
Retry Behavior.Does Not Retry On 401 4027ms
Retry Behavior.Respects Retry After Header 7026ms
Retry Behavior.Implements Backoff 17035ms
Retry Behavior.Retries On 500 7020ms
Retry Behavior.Retries On 502 7020ms
Retry Behavior.Retries On 504 7018ms
Retry Behavior.Max Retries Respected 17037ms
Deduplication.Generates Unique Uuids 39ms
Deduplication.Preserves Uuid On Retry 7018ms
Deduplication.Preserves Uuid And Timestamp On Retry 12030ms
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 19ms
Batch Format.Uses Proper Batch Structure 21ms
Batch Format.Flush With No Events Sends Nothing 11ms
Batch Format.Multiple Events Batched Together 33ms
Error Handling.Does Not Retry On 403 4020ms
Error Handling.Does Not Retry On 413 4023ms
Error Handling.Retries On 408 5026ms

Feature_Flags Tests

17/17 tests passed

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

turnipdabeets and others added 6 commits September 8, 2026 09:35
… 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
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
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
The config KDoc named capturePushNotificationOpened without saying which
one: the Intent overload dedupes against the automatic path, the
title/body/payload one does not. Wiring the latter into onNewIntent
double-counts every warm tap.

Also names the PostHogFake fixture change in the changeset, since that
artifact is published.

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

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt:107-112
**Rejected config stays active**

When `PostHogAndroid.setup()` is called while the shared client is already active, `PostHog.setup(config)` returns without applying the new configuration, but `androidConfig` is still replaced. The manual push-open API then uses the rejected configuration’s feature gate and preferences while sending through the original client, which can suppress valid opens or store dedupe IDs under the wrong project.

### Issue 2
posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt:43
**Dedupe crosses project boundaries**

`lastHandledPushMessageId` is process-wide and remains set across setup lifecycles, while persisted dedupe state is scoped to each API key. After closing one project and setting up another, a notification with the same message ID is rejected using the previous project’s in-memory state, so the new project loses a valid push-open event.

---

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

Reviews (1): Last reviewed commit: "docs(push): point warm-start users at th..." | Re-trigger Greptile

Comment thread posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt Outdated
PostHog.setup() returns early when an instance is already active, and
again on an empty API key — both before it assigns this.config. The
Android wrapper adopted the config regardless, so the manual push-open
entry would read the rejected config's feature gate and write dedupe ids
into its preferences while events kept going to the original instance.

Adopt it only when the core actually took it, by identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
@turnipdabeets
turnipdabeets merged commit 5e3267b into main Sep 8, 2026
17 checks passed
@turnipdabeets
turnipdabeets deleted the fix/push-open-late-install-clean branch September 8, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants