feat(react-native): capture Android push opens when the process was killed - #4929
Conversation
…lled Android delivers a notification tap through Activity.onNewIntent while React Native's context is still starting. ReactHostImpl.onNewIntent drops the intent when getCurrentReactContext() is null, ReactDelegate.onNewIntent still reports it handled so ReactActivity never calls super.onNewIntent, and nothing calls setIntent. The tap is then invisible to every library in the process. Inject a MainActivity.onNewIntent override through the Expo config plugin that calls setIntent(intent) before delegating, so getIntent() is correct for the rest of the process.
Expo's mainActivity base mod globs android/app/src/main/java only and asserts, so registering it turned sources under src/main/kotlin - or no MainActivity at all - into a hard expo prebuild failure whose message never mentions PostHog. Do the lookup in a dangerous mod over both java and kotlin source roots and warn-and-skip when nothing matches. Also from review: match a CRLF managed block so the opt-out still removes it, require an onNewIntent declaration rather than the bare token before backing off, and announce the write the way disableSandboxing does.
Prompt To Fix All With AI### Issue 1
packages/react-native/src/tooling/expoconfig.ts:356-359
**Wrong activity may be patched**
When multiple packages under these source roots contain a `MainActivity`, this traversal patches the first filesystem match instead of the manifest-declared launcher. The real activity remains unpatched, so killed-process notification taps are still lost, while an unrelated source file is modified. Resolve the launcher class from the Android manifest and patch its corresponding source file.
### Issue 2
packages/react-native/src/tooling/expoconfig.ts:316
**Helper overrides disable patching**
The existing-override check scans the entire file, so an `onNewIntent` method in a helper or second class is mistaken for an override on `MainActivity`. The plugin then skips injection, leaving the launcher vulnerable to lost notification taps. Restrict this check to direct methods in the identified `MainActivity` body.
### Issue 3
packages/react-native/src/tooling/expoconfig.ts:297
**Literal braces prevent patching**
`matchingBraceIndex` counts braces inside comments and string literals as structural braces. A valid `MainActivity` containing a lone brace in a comment or string can therefore be treated as unbalanced, causing the plugin to skip the override and leave killed-process notification taps uncaptured. Parse or scan the class body while ignoring comments and literals.
### Issue 4
.changeset/rn-android-main-activity-new-intent.md:5
**Changeset includes implementation details**
The repository requires changesets to contain only a short, user-facing description of the fix or feature. This entry instead describes the config-plugin injection and its opt-out property. Reduce it to the user-visible Android push-open fix; this repository requirement must be satisfied before merging.
```suggestion
Fix `$push_notification_opened` not being captured on Android when the app's process was killed but its task stayed in recents.
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(react-native): find MainActivity our..." | Re-trigger Greptile |
dustinbyrne
left a comment
There was a problem hiding this comment.
Consent before setup-time capture
Consider applying the current JS consent state before setup-time tap capture. The recovered Intent can reach native setup while persisted native opt-in disagrees with the new JS client's opt-out. The retained-task notification path needs a two-launch test with memory persistence, defaultOptIn: false, and nativeCrashes: true.
Launcher selection
Consider resolving the launcher from the manifest rather than selecting the first MainActivity basename. An auxiliary Java MainActivity can currently win over the actual Kotlin launcher, leaving the intended activity unpatched.
Override detection
Consider limiting existing-override detection to the target class. An unrelated helper class's onNewIntent declaration currently suppresses the patch.
Brace matching
Consider making brace matching aware of strings and comments. A valid declaration such as private val delimiter = "{" currently makes the scan report unbalanced source and skip the patch.
The check scanned the whole file, so an onNewIntent on a helper class in the same source file made the plugin skip the override and leave killed-process notification taps uncaptured. Resolve MainActivity's body first and look for the existing override only inside it. Trim the changeset to the user-facing line per RELEASING.md.
|
Thanks @dustinbyrne — all four addressed. Consent before setup-time capture — real, and filed as #4965 rather than patched here. The scenario is reachable: It isn't introduced by this PR, though: the diff touches no runtime code at all — it's Launcher selection — leaving as is. Expo's own Override detection — good catch, fixed in 8649e6b. We resolve MainActivity's body span first and test for the existing override only inside it, so a helper class's Brace matching — leaving as is. |
dustinbyrne
left a comment
There was a problem hiding this comment.
Re-reviewed 8649e6b5aadc0756f263a23a9b3d2e5b3632b5fe. I’m withdrawing my earlier manifest-selection request; the helper-class override detection is addressed. Two issues remain:
Consent on newly recovered taps
Consider gating the setup-time intent drain on the original incoming JS consent before enabling this new recovery path. With JS memory persistence, defaultOptIn: false, native crash capture enabled, and native opt-in retained from a previous launch, the generated override preserves a retained-task notification arriving before React is ready. Native setup restores its earlier consent and the bridge drains the intent before resolving setup; JS reasserts denial afterward.
The ordering defect is older, but base loses this particular early input while head admits it. #4965 tracks the broader issue without mitigating the added path. A narrow guard needs the corresponding plugin fix/release. A denied candidate should not reappear after later opt-in/repeated drain, while a genuinely new allowed tap should still capture. Consider a real two-launch retained-task regression that asserts no matching native queue event, with allowed-consent and fresh-storage controls.
A closing-brace literal can duplicate an existing override
The material brace case is "}" before an existing onNewIntent, rather than the opening-brace case that safely skips the file. The scanner treats that literal as the class end, so the scoped check misses the real override and inserts a duplicate method. Valid customized Kotlin/Java source becomes uncompilable.
Consider ignoring literals/comments in the boundary scan or conservatively refusing ambiguous source. Kotlin and Java fixtures with a "}" field before an existing override should preserve the source and compile; keep the stock-host and later-sibling cases as controls.
These are source-validated findings; I did not execute the proposed regressions or observe a device upload.
…ivity A "}" string before an existing onNewIntent ended the class early, hid the override from the scoped check, and made the plugin insert a duplicate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F44TnppkycioLyfGN5tewi
…enies Native prefers the opt-out it persisted itself, so a JS optOut: true could not stop the cold-start drain after an earlier launch opted in; the tap is now consumed without capture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F44TnppkycioLyfGN5tewi
The version numbers were written before the releases and the trains went to other features in between: - React Native cold-launch capture and the dedupe are in @posthog/react-native-plugin 2.9.2 (#4921), not 2.8.2 - the Flutter pre-setup tap fix is in 5.42.1 (#579), not 5.41.1 - the Flutter dedupe is in 5.43.1 (#578), not 5.41.1 posthog-react-native's Expo override is left as TODO_4929_VERSION: PostHog/posthog-js#4929 has not merged, so no release carries it yet.
|
@dustinbyrne the general fix for the consent point you raised is now up as drafts:
Both add Why a native flag rather than doing it all in the plugin, since that was the obvious cheaper path: the set isn't the one drain we guarded here, it's everything gated on Verified on device on both platforms with a falsifying control: with the flag off an opted-out user's event is captured (1); with it on it isn't (0). Your two corrections earlier were both right and are reflected in the write-ups — the The guards in this PR stay until the plugin adopts the flag and bumps its native floor; they get deleted in that same PR. Tracking in #4965. |
dustinbyrne
left a comment
There was a problem hiding this comment.
Reviewed 46ded754084f089ac321f749b83148ea784e4616. The new original-consent guard and ordinary-literal handling address the earlier findings. Two cases remain:
Safe native-plugin version boundary
At expoconfig.ts:1092, recovery is enabled by default, but RN still accepts explicitly installed native plugin 2.8.0 through the optional peer >= 2.4.3. That published plugin lacks the consent guard.
The affected combination is a new-architecture Android app with a retained task, a prior persisted native opt-in, and JS configured with persistence: 'memory', defaultOptIn: false, and errorTracking: { autocapture: { nativeCrashes: true } }. Native crash setup still runs while JS denies analytics. If a notification tap arrives before React is ready, the new host override preserves it, and plugin 2.8.0 can enqueue the push-open event before await setup returns and JS reasserts opt-out.
Could we require the first guard-containing native release or skip unsafe installed combinations, and document upgrading both packages? Changesets leaves this non-workspace peer range unchanged, so releasing both changesets does not establish that boundary. The later native persistOptOut work does not change an installed 2.8.0 consumer.
Regression to add: the two-launch mixed-package case must enqueue/send zero denied notification events. Controls should include the guarded plugin, fresh native storage with denial, and an allowed tap. This is source-backed; I did not execute that device regression.
Nested Kotlin block comments
At expoconfig.ts:337–343, taking the first */ does not handle Kotlin's nested comments. This valid host illustrates the problem:
import com.facebook.react.ReactActivity
class MainActivity : ReactActivity() {
/*
fun retiredHandler() {
/* retired implementation */
}
*/
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
}
}The scanner treats the commented-out function's closing } as the class end. It misses the real override below it and inserts a second active override, breaking compilation. Could we track nested Kotlin comments or conservatively skip such source? A transform regression should leave this fixture unchanged with one override and the existing-override warning.
Validation was static inspection and existing CI, including independent review of both findings. The exact mixed-package and nested-comment regressions were not run; existing CI used an integration tree rather than the isolated PR head.
…ivity scan Kotlin nests block comments; Java does not. Taking the first `*/` ended a commented-out function early, so its closing brace read as the end of the class, the scoped check missed the real onNewIntent below it, and prebuild inserted a second override the file no longer compiles with. Also raise the optional @posthog/react-native-plugin floor to the release carrying the setup-time consent check, so the recovery this mod enables cannot pair with a plugin that drains the recovered tap unguarded.
|
Thanks @dustinbyrne — both addressed in 601fd96. Nested Kotlin block commentsReal bug, fixed. Kotlin nests block comments and Java doesn't, so the scan now tracks depth for Verified it earns its place: reverting to the old first- Safe native-plugin version boundaryRaised the optional peer floor: I also built the "skip unsafe installed combinations" half — a prebuild check reading the installed plugin's
So the peer range is the declarative boundary, and I'd rather not pretend a version gate enforces it. Worth saying plainly: a peer range warns, it doesn't block, so a determined pin can still reach the unsafe combination. What actually removes the hazard class is not version-gating but deleting the second consent store, which is now up:
With On the regressions you asked forNot run, and I won't claim otherwise. What I do have on device, on both platforms, is the |
The class declaration was found with a plain regex over raw source while the brace scan was comment-aware, so the two disagreed: a commented-out `class MainActivity` above the real one won the regex, and prebuild wrote the override inside that comment — leaving the real override in place and emitting uncommented lines at top level, which does not compile. Mask comments and string/char literals once, then run the declaration match, the brace scan and the existing-override check over that. One definition of what counts as code, so a `"}"` field, a commented-out class, and an onNewIntent named in a comment are invisible to all three.
dustinbyrne
left a comment
There was a problem hiding this comment.
The peer-version boundary and scanner fixes address my feedback. Reviewed 01c199e6bf797042c59f7773882aea9bcf59df34; no remaining code-review concerns. Merge remains subject to passing CI.
- React Native: the manual/automatic dedupe shipped in plugin 2.9.1, not 2.9.2 (2.9.2 is the iOS cold-launch hook, which the other references cite correctly) - Android: the two dedupe mechanisms are not the same. The message-ID dedupe on the intent path applies to any FCM notification; only the invocation-id dedupe is PostHog-only. The paragraph claimed the second rule for both. - iOS: on iOS 13 only the push hooks are skipped, not all capture - iOS: note that the field-based call carries no notification identifier, so a genuine re-send inside the window reads as the same tap Names posthog-react-native 4.74.0 for the Expo override, which is the release that will carry PostHog/posthog-js#4929. Do not merge before that release.
* docs(push): correct push open claims that are wrong today - React Native: iOS taps that cold-launch the app are not captured, and automatic capture needs iOS 14 or newer - React Native: Android captures warm-start taps since plugin 2.6.0, except when the process was killed with its task still in the recent apps list - React Native: promote the notification-delegate requirement to a callout, and add the missing $push_notification_opened troubleshooting row - iOS and Flutter: state the iOS 14 floor for the automatic hooks - Flutter: name the plugin version instead of native floors the plugin no longer declares * docs(push): document the push open behavior the SDK releases add - Android, Flutter, iOS: a repeat of a PostHog-sent notification captured in the last 5 minutes is skipped, so one tap counts once - React Native: iOS captures a tap that cold-launches the app, and capturePushNotificationOpened: false no longer stops that launch hook - document the Info.plist key that does - React Native: document the MainActivity onNewIntent override for taps that arrive after the process was killed, via the Expo config plugin or by hand - React Native: replace the manual open guidance with the dedupe rule - Flutter: a tap that lands before setup() is replayed once setup completes - iOS: the React Native plugin now prewarms open capture too * docs(push): the dedupe now reaches iOS on both plugins * docs(push): say per platform what notification content is captured * docs(push): correct the SDK versions to what actually shipped The version numbers were written before the releases and the trains went to other features in between: - React Native cold-launch capture and the dedupe are in @posthog/react-native-plugin 2.9.2 (#4921), not 2.8.2 - the Flutter pre-setup tap fix is in 5.42.1 (#579), not 5.41.1 - the Flutter dedupe is in 5.43.1 (#578), not 5.41.1 posthog-react-native's Expo override is left as TODO_4929_VERSION: PostHog/posthog-js#4929 has not merged, so no release carries it yet. * docs(push): correct two dedupe claims and scope the iOS 13 note - React Native: the manual/automatic dedupe shipped in plugin 2.9.1, not 2.9.2 (2.9.2 is the iOS cold-launch hook, which the other references cite correctly) - Android: the two dedupe mechanisms are not the same. The message-ID dedupe on the intent path applies to any FCM notification; only the invocation-id dedupe is PostHog-only. The paragraph claimed the second rule for both. - iOS: on iOS 13 only the push hooks are skipped, not all capture - iOS: note that the field-based call carries no notification identifier, so a genuine re-send inside the window reads as the same tap Names posthog-react-native 4.74.0 for the Expo override, which is the release that will carry PostHog/posthog-js#4929. Do not merge before that release.
Problem
On Android, a push notification tap is silently lost when the app's process was killed but its task is still in recents (the state you get from a low-memory kill, or
adb shell am kill <pkg>). Nothing is captured, and the tap is invisible to every library in the process — not just ours.This is a React Native defect. The tap arrives at
Activity.onNewIntentwhile the React context is still starting, and three things line up:ReactHostImpl.onNewIntent(ReactAndroid/.../runtime/ReactHostImpl.java:800-821) drops the intent whengetCurrentReactContext() == null— it only raises a soft exception.ReactDelegate.onNewIntentreturnstrueunconditionally on the new architecture, soReactActivitynever callssuper.onNewIntent(intent)— which also means the AndroidXOnNewIntentListenerchain never runs.setIntent(intent), sogetIntent()keeps returning the original launch intent forever.Consequences in a plain Expo/RN app:
ActivityEventListener.onNewIntentnever fires (no React context yet, and the delegate swallowed it),activity.intentstill holds the original launch intent when the bridge reachessetup(), so the cold-start capture path finds nothing,@react-native-firebase/messaging'sgetInitialNotification()returnsnullfor the same reason, and deep links are lost too.Related: #4858, #4919, PostHog/posthog-android#783.
Changes
The host-side fix is one line — call
setIntent(intent)beforesuper.onNewIntent(intent)inMainActivity. That makesgetIntent()correct for the rest of the process, so our cold-start path picks the tap up atsetup(), and it repairs Firebase'sgetInitialNotification()and deep links at the same time.This PR injects that override through the existing Expo config plugin, so Expo and prebuild apps need no native code:
withDangerousMod('android', …)inpackages/react-native/src/tooling/expoconfig.ts, alongside the existingwithAppBuildGradle/withXcodeProjectmods. It findsMainActivity.{kt,java}underandroid/app/src/main/{java,kotlin}itself, and warns and writes nothing when there is none — Expo's ownmainActivitybase mod only globssrc/main/javaandasserts, which would turn a project with sources insrc/main/kotlin(or noMainActivityat all) into a hardexpo prebuildfailure whose message never mentions PostHog. Dangerous mods run before the standard android chain, so another plugin'swithMainActivitystill reads and re-writes our edit. Kotlin and JavaMainActivityboth supported (the parameter is spelledandroid.content.Intentso no import edit is needed).disableSandboxingfollows, since this edits the app's own source.disableSandboxing: on by default,{ patchMainActivityNewIntent: false }skips it — and also removes a block a previous prebuild wrote, matching howdotenvFile/releaseModealready behave.@generated begin/end posthog-new-intentmarkers and rewritten in place, so repeated prebuilds never stack copies.MainActivitythat already overridesonNewIntentis left alone with a warning telling the developer to addsetIntent(intent)as the first statement of their own override. Two overrides would not compile, and we must never reorder someone'ssetIntentafter theirsupercall.MainActivitywe recognize (no class body found between the class name and a{, or unbalanced braces), the plugin warns and writes nothing rather than guessing. The class-body scan ignores string and character literals, raw strings, Kotlin${...}templates and comments, so a"}"field cannot end the class early and hide a realonNewIntentoverride from the check; an unterminated literal is treated as unrecognizable source and skipped.@posthog/react-native-plugin(Android): a setup-time consent guard. This override feeds a tap that arrived while React was starting into the plugin's existing cold-start drain insetup(). That drain runs before the JS layer can re-assert its consent, and the native SDK lets the opt-out it persisted itself outrank theoptOutJS passes in (React Native: persisted native opt-in overrides JS opt-out, so setup-time native captures bypass consent #4965), so after an earlier launch opted in, a tap the JS client considers denied was captured. The drain now takes theoptOutJS passed into thissetup(): when it istrue, the tap's marker is stripped without capturing, so a later opt-in or repeated drain cannot resurrect it. Scoped to that one drain; later taps still go through native consent as before. The broader inversion ($exceptionreplay, session replay) stays with React Native: persisted native opt-in overrides JS opt-out, so setup-time native captures bypass consent #4965.Bare React Native apps that never run
expo prebuildstill need the override by hand; the docs snippet for that is going up on posthog.com separately.Verification
Bare RN example (
examples/example-rn-native-plugin,@react-native-firebase/messaging, new architecture,launchMode="singleTask"), real FCM tray notification posted overcom.google.android.c2dm.intent.RECEIVEand tapped through the notification shade with real UI input,$push_notification_openedcounted from/batchpayloads against a local mock. Noexpo prebuild— the example is bare RN, so the plugin's generated block was applied to itsMainActivity.ktverbatim.am kill, task still in recentsFirebase's
getInitialNotification()went fromnullto the message id in theam killcase, confirming the same root cause and that the fix repairs it too.launchModeis not the cause: the sameam killscenario against the RN example switched toandroid:launchMode="singleTop"(no source patch) still captured 0 events. Flutter surviving this is not down to itssingleToplaunch mode, so there is no cheaper manifest-only mitigation.Consent guard (review follow-up)
Same bare RN example on a Pixel 9 emulator (API 37), driving the plugin's
setup()directly with theoptOuta JS client would pass,flushAt: 1, native crash capture on,$push_notification_openedcounted from/batchbodies at a local mock and fromcache/posthog-disk-queuein the app's data dir. "Retained-task tap" isam killwith the task left in recents, then the tray tap's intent. Control = this branch without the guard commit; guard = with it.opt-out=false), launch 2 passesoptOut: true, retained-task tapoptOut: true, retained-task tapoptOut: false, then a warm tapThe plugin transform itself is covered by unit tests (fresh Kotlin, fresh Java, already-patched no-op, pre-existing
onNewIntentoverride, a comment that merely mentionsonNewIntent, a CRLF file, a second class in the same file, opt-out removal, unrecognizable file, a"}"literal before an existing override in Kotlin and Java, lone braces in literals/raw strings/templates/comments in both languages, an unterminated string) plus end-to-endcompileModsAsynctests through Expo's real mod compiler covering the standardsrc/main/javalayout,src/main/kotlin, aMainActivity.java, and a project with noMainActivity(warns, does not throw, writes nothing). The generated block was also run through the mod compiler againstexamples/example-expo-57's real prebuiltMainActivity.ktand compiled by Gradle.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code (Opus 5) implemented this from an investigation that had already isolated the React Native code path. Tools: Read/Edit/Bash, Android emulator on a dedicated AVD instance, vitest.
Decisions along the way:
Activitybefore any React code exists, so no amount of work insidePosthogReactNativePluginModulecan see the intent. PatchingMainActivityis the only place it can go.disableSandboxing: underexpo prebuildMainActivityis a generated file, the override is what Android's own docs prescribe, and an app that already overridesonNewIntentis skipped rather than patched.launchMode="singleTop"as a cheaper manifest-only mitigation. Measured on the emulator, it does not help (still 0 events), so the source patch is the fix.import android.content.Intent. Fully qualifying the parameter type keeps the transform to a single insertion point and cannot collide with an existing import.mainActivitybase mod. It resolves the file with a glob oversrc/main/javaonly andasserts, so a project with sources insrc/main/kotlin, a hand-maintainedandroid/folder, or noMainActivityat all would get a hardexpo prebuildfailure that never names PostHog.expo-splash-screenregisters the same mod in the default template, but it is removable, so relying on it is conditional, not universal. AwithDangerousModthat does its own lookup and warns when it finds nothing has neither problem, and picks upsrc/main/kotlinbesides.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.
firebase_messagingMerge 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 is published. Docs: #20114 can go now; #20102 last, after the releases.
Earlier work this builds on: PostHog/posthog-android#753, PostHog/posthog-ios#792, #4858, PostHog/posthog-flutter#556, PostHog/posthog-flutter#557, PostHog/posthog.com#19905.