Pre-submission checklist
react-native-notify-kit version
10.5.0
React Native version
0.86.2
Platform
Android
OS version
Android 16
Description
What happened:
With two or more notifications live in the tray, notification action buttons operate on the
wrong notification:
- Pressing one notification's action button delivers the event carrying a different
notification's notification and pressAction bundles. In my app, pressing "✓ Prayed" on
one prayer marked a different prayer as prayed.
- Swiping one notification away also removes an unrelated notification from the tray (the
delete intent fires with another notification's payload). No groupId is set anywhere, so
this is not Android group behaviour.
- Once this happens, the remaining notifications' buttons stop responding entirely — in the
foreground as well as background. Only swipe-dismiss still works.
It is intermittent: it depends on whether the app process restarted between two notifications
being posted (see root cause below — the request code counter resets on process start).
A single notification in the tray always behaves correctly. Tapping the notification body
always works correctly too, even while its action buttons are broken — only action buttons and
delete intents are affected.
What I expected:
Pressing an action button delivers the event for the notification whose button was pressed,
and swiping a notification dismisses only that notification — regardless of how many
notifications are live or whether the app process restarted in between.
Minimal reproduction
The symptoms above were observed repeatedly in production in my own app (a prayer-times app
posting several notifications a day, each with three action buttons). The snippet below isolates
the conditions, derived from the ReceiverService code path.
The key requirement is that the two notifications are posted in different process lifetimes,
because the PendingIntent request code is a static counter that resets to 0 on process start.
Posting both within one app session will not reproduce it.
import notifee, { EventType } from 'react-native-notify-kit';
async function post(id, title) {
const channelId = await notifee.createChannel({ id: 'repro', name: 'Repro' });
await notifee.displayNotification({
id,
title,
data: { which: id },
android: {
channelId,
// No launchActivity / mainComponent => headless action
// => routed to ReceiverService.ACTION_PRESS_INTENT
actions: [{ title: 'Done', pressAction: { id: `done-${id}` } }],
},
});
}
notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.ACTION_PRESS) {
console.log('pressed:', detail.pressAction?.id, '| payload:', detail.notification?.data?.which);
}
});
// Wire post('notif-a', 'A') and post('notif-b', 'B') to two buttons.
Steps:
- Tap the button that calls
post('notif-a', 'A') — notification A appears in the tray.
- Swipe the app away from recents so the process is killed. Leave A in the tray.
- Reopen the app (fresh process — the counter restarts at 0) and call
post('notif-b', 'B').
Both A and B are now live in the tray.
- Press A's "Done" button.
Expected log:
pressed: done-notif-a | payload: notif-a
Actual log:
pressed: done-notif-b | payload: notif-b
Swiping A away instead of pressing it removes B as well, via the same collision on the delete
intent.
Logs
No logs to attach — this failure produces no log output.
There is no crash, exception, or warning at any layer. Android resolves and delivers the
PendingIntent successfully; it simply carries another notification's extras. From logcat's point
of view the press is handled normally and the "wrong" payload is indistinguishable from a correct
one, so a capture would show a perfectly ordinary, successful action-press event.
The evidence is therefore in the source rather than the log stream — see the root cause in
Additional context, which identifies the exact lines and explains why the collision occurs.
Happy to attach a logcat capture if it would still help; just say what you'd like to see.
Additional context
Device: Samsung Galaxy S22 Ultra, One UI 8
Root cause
android/src/main/java/app/notifee/core/ReceiverService.java:
private static final AtomicInteger uniqueIds = new AtomicInteger(0); // L49
...
int uniqueInt = uniqueIds.getAndIncrement(); // L79
return PendingIntent.getService(
context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
uniqueIds is unique only within a single process lifetime. Android matches PendingIntents on
request code + Intent (action, component, data, categories) and ignores extras. Every intent
built here shares the same component (ReceiverService) and one of three constant actions, so
the request code is the only discriminator — while the notification id and pressAction.id
travel purely as extras.
So when the process restarts, the counter returns to 0 and newly posted notifications are handed
request codes still held by live tray notifications. FLAG_UPDATE_CURRENT then overwrites the
older PendingIntent's extras with the newer notification's bundles, and pressing the older
notification's button delivers the newer one's payload.
This is easy to hit with trigger notifications specifically, since they are often posted from a
fresh headless process (rehydrated from Room after an app kill), where the counter always starts
at 0.
Why the delete intent is affected too
setDeleteIntent has no branch — it always uses ReceiverService.createIntent
(NotificationManager.java L160-164), so every notification's delete intent is exposed
regardless of its pressAction configuration. That's the collateral-dismissal symptom.
Why tapping the body is unaffected
A body pressAction of { id: 'default', launchActivity: 'default' } routes to
NotificationPendingIntent.createIntent, which uses a collision-free request code:
int uniqueInt = UUID.randomUUID().hashCode(); // NotificationPendingIntent.java L78
(validateAndroidPressAction only defaults launchActivity when id === 'default', so ordinary
action buttons never receive launch metadata.)
Relationship to #38
#38 fixed headless action buttons foregrounding the app by reusing
shouldCreateLaunchActivityIntent(...) when selecting the action's PendingIntent implementation
(NotificationManager.java L455-475). That change is correct in intent, but it moved every
headless action button off the activity path (unique UUID request codes) and onto
ReceiverService (the resetting counter) — which is where this collision lives. So it can't be
addressed by reverting #38; ReceiverService needs request codes that are unique and stable in
their own right.
Suggested fix
Derive the request code from data already passed into createIntent — the notification id, plus
the pressAction id where present — instead of the counter:
StringBuilder key = new StringBuilder(action);
for (Bundle b : extraBundles) {
if (b != null) key.append(':').append(b.getString("id"));
}
int uniqueInt = key.toString().hashCode();
Every call site already supplies these bundles ({"notification"} for the delete intent,
{"notification", "pressAction"} for press/action intents), and both carry an "id" key —
NotificationModel.getId() reads mNotificationBundle.getString("id"). This yields distinct,
restart-stable codes such as ACTION_PRESS_INTENT:notif-a:done vs ACTION_PRESS_INTENT:notif-b:done.
Stable is preferable to random here: it keeps FLAG_UPDATE_CURRENT doing what it is meant to do —
a re-posted notification (e.g. a DAILY repeat) updates its own PendingIntent rather than
hijacking a neighbour's or leaking a new one on every reschedule.
NotificationModel.getHashCode() (getId().hashCode()) already exists if you would prefer to
build on that.
Related
Upstream Notifee #196 reports the same
user-visible behaviour (multiple trigger notifications with quick actions; press delivers the
wrong one; explicitly intermittent).
Architecture
Pre-submission checklist
react-native-notify-kit version
10.5.0
React Native version
0.86.2
Platform
Android
OS version
Android 16
Description
What happened:
With two or more notifications live in the tray, notification action buttons operate on the
wrong notification:
notification's
notificationandpressActionbundles. In my app, pressing "✓ Prayed" onone prayer marked a different prayer as prayed.
delete intent fires with another notification's payload). No
groupIdis set anywhere, sothis is not Android group behaviour.
foreground as well as background. Only swipe-dismiss still works.
It is intermittent: it depends on whether the app process restarted between two notifications
being posted (see root cause below — the request code counter resets on process start).
A single notification in the tray always behaves correctly. Tapping the notification body
always works correctly too, even while its action buttons are broken — only action buttons and
delete intents are affected.
What I expected:
Pressing an action button delivers the event for the notification whose button was pressed,
and swiping a notification dismisses only that notification — regardless of how many
notifications are live or whether the app process restarted in between.
Minimal reproduction
The symptoms above were observed repeatedly in production in my own app (a prayer-times app
posting several notifications a day, each with three action buttons). The snippet below isolates
the conditions, derived from the
ReceiverServicecode path.The key requirement is that the two notifications are posted in different process lifetimes,
because the PendingIntent request code is a static counter that resets to 0 on process start.
Posting both within one app session will not reproduce it.
Steps:
post('notif-a', 'A')— notification A appears in the tray.post('notif-b', 'B').Both A and B are now live in the tray.
Expected log:
Actual log:
Swiping A away instead of pressing it removes B as well, via the same collision on the delete
intent.
Logs
Additional context
Device: Samsung Galaxy S22 Ultra, One UI 8
Root cause
android/src/main/java/app/notifee/core/ReceiverService.java:uniqueIdsis unique only within a single process lifetime. Android matches PendingIntents onrequest code + Intent (action, component, data, categories) and ignores extras. Every intent
built here shares the same component (
ReceiverService) and one of three constant actions, sothe request code is the only discriminator — while the notification id and
pressAction.idtravel purely as extras.
So when the process restarts, the counter returns to 0 and newly posted notifications are handed
request codes still held by live tray notifications.
FLAG_UPDATE_CURRENTthen overwrites theolder PendingIntent's extras with the newer notification's bundles, and pressing the older
notification's button delivers the newer one's payload.
This is easy to hit with trigger notifications specifically, since they are often posted from a
fresh headless process (rehydrated from Room after an app kill), where the counter always starts
at 0.
Why the delete intent is affected too
setDeleteIntenthas no branch — it always usesReceiverService.createIntent(
NotificationManager.javaL160-164), so every notification's delete intent is exposedregardless of its
pressActionconfiguration. That's the collateral-dismissal symptom.Why tapping the body is unaffected
A body
pressActionof{ id: 'default', launchActivity: 'default' }routes toNotificationPendingIntent.createIntent, which uses a collision-free request code:(
validateAndroidPressActiononly defaultslaunchActivitywhenid === 'default', so ordinaryaction buttons never receive launch metadata.)
Relationship to #38
#38 fixed headless action buttons foregrounding the app by reusing
shouldCreateLaunchActivityIntent(...)when selecting the action's PendingIntent implementation(
NotificationManager.javaL455-475). That change is correct in intent, but it moved everyheadless action button off the activity path (unique
UUIDrequest codes) and ontoReceiverService(the resetting counter) — which is where this collision lives. So it can't beaddressed by reverting #38;
ReceiverServiceneeds request codes that are unique and stable intheir own right.
Suggested fix
Derive the request code from data already passed into
createIntent— the notification id, plusthe
pressActionid where present — instead of the counter:Every call site already supplies these bundles (
{"notification"}for the delete intent,{"notification", "pressAction"}for press/action intents), and both carry an"id"key —NotificationModel.getId()readsmNotificationBundle.getString("id"). This yields distinct,restart-stable codes such as
ACTION_PRESS_INTENT:notif-a:donevsACTION_PRESS_INTENT:notif-b:done.Stable is preferable to random here: it keeps
FLAG_UPDATE_CURRENTdoing what it is meant to do —a re-posted notification (e.g. a
DAILYrepeat) updates its own PendingIntent rather thanhijacking a neighbour's or leaking a new one on every reschedule.
NotificationModel.getHashCode()(getId().hashCode()) already exists if you would prefer tobuild on that.
Related
Upstream Notifee #196 reports the same
user-visible behaviour (multiple trigger notifications with quick actions; press delivers the
wrong one; explicitly intermittent).
Architecture