feat(push): capture a launch intent the SDK was installed too late to read - #753
Merged
Conversation
5 tasks
Contributor
posthog-android Compliance ReportDate: 2026-09-08 14:35:09 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
turnipdabeets
force-pushed
the
fix/push-open-late-install-clean
branch
from
September 8, 2026 01:30
aeb5ade to
abb55e9
Compare
… 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
turnipdabeets
force-pushed
the
fix/push-open-late-install-clean
branch
from
September 8, 2026 13:37
3bb41ee to
2831ee3
Compare
turnipdabeets
marked this pull request as ready for review
September 8, 2026 14:11
This was referenced Sep 8, 2026
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 |
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
dustinbyrne
approved these changes
Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔗 Related PRs
One fix, five PRs — three SDKs plus the docs. Each Flutter PR is gated on the native release it depends on.
setup()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_openedis never captured in a Flutter app.The tray intent is read in
onActivityCreated, andinstall()only callsregisterActivityLifecycleCallbacks— 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:Every lifecycle callback for the launch Activity is missed. Native apps that call
setup()fromApplication.onCreateare 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 andgoogle.message_iddedupe with the automatic path, so calling both cannot double-count.pushOpenedMessageIds, a bounded history). A caller with nosavedInstanceStatecannot 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.onActivityCreatedkeepssavedInstanceStateas 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:apiCheckclean.ALL_INTERNAL_KEYSentry,the automatic path still captures a genuine re-tap in a new process, andwith after setup must not redirect the manual entry to the secondary project.google.message_idextra → 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()inApplication.onCreate, plus the newonNewIntentforwarding this PR adds):origin/main, confirming no regressiononNewIntentA 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 thisminorstill 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_HOSThook), so every assertion above is made against the exact/batchbody the SDK sent, not a dashboard query.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileThe changeset declares
posthogas well asposthog-android— the coreALL_INTERNAL_KEYSentry 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/onActivityResumedwas 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:
ALL_INTERNAL_KEYS, so it rode on every event as a super property.getAll()feedsbuildProperties(); every other internal key is listed.?: returnon aUnitfunction), and the twosynchronizedblocks 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, whichActivityLifecycleCallbacksdoes 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