react-native-notify-kit 9.3.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +68 -2
  2. package/android/build.gradle +5 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  5. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +204 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  7. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  8. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  9. package/android/src/main/baseline-prof.txt +382 -0
  10. package/android/src/main/java/app/notifee/core/ForegroundService.java +269 -80
  11. package/android/src/main/java/app/notifee/core/InitProvider.java +48 -5
  12. package/android/src/main/java/app/notifee/core/Logger.java +5 -0
  13. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +160 -79
  14. package/android/src/main/java/app/notifee/core/NotificationManager.java +370 -288
  15. package/android/src/main/java/app/notifee/core/WarmupHelper.java +84 -0
  16. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  17. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +17 -1
  18. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +30 -0
  19. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +182 -0
  20. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  21. package/dist/NotifeeApiModule.d.ts +1 -0
  22. package/dist/NotifeeApiModule.js +6 -0
  23. package/dist/NotifeeApiModule.js.map +1 -1
  24. package/dist/specs/NativeNotifeeModule.d.ts +1 -0
  25. package/dist/specs/NativeNotifeeModule.js.map +1 -1
  26. package/dist/types/Module.d.ts +19 -0
  27. package/dist/types/NotificationAndroid.d.ts +58 -0
  28. package/dist/types/NotificationAndroid.js +43 -0
  29. package/dist/types/NotificationAndroid.js.map +1 -1
  30. package/dist/utils/validate.d.ts +1 -0
  31. package/dist/utils/validate.js +10 -4
  32. package/dist/utils/validate.js.map +1 -1
  33. package/dist/validators/validateAndroidChannel.js +3 -3
  34. package/dist/validators/validateAndroidChannel.js.map +1 -1
  35. package/dist/validators/validateAndroidNotification.js +36 -12
  36. package/dist/validators/validateAndroidNotification.js.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +5 -1
  40. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +5 -5
  41. package/ios/NotifeeCore/NotifeeCore.m +28 -9
  42. package/ios/NotifeeCore/NotifeeCoreDelegateHolder.m +5 -1
  43. package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +7 -2
  44. package/ios/RNNotifee/NotifeeApiModule.mm +31 -10
  45. package/package.json +1 -1
  46. package/src/NotifeeApiModule.ts +7 -0
  47. package/src/specs/NativeNotifeeModule.ts +1 -0
  48. package/src/types/Module.ts +20 -0
  49. package/src/types/NotificationAndroid.ts +62 -0
  50. package/src/utils/validate.ts +12 -4
  51. package/src/validators/validateAndroidChannel.ts +3 -3
  52. package/src/validators/validateAndroidNotification.ts +43 -10
  53. package/src/version.ts +1 -1
@@ -31,6 +31,7 @@ import android.graphics.Bitmap;
31
31
  import android.net.Uri;
32
32
  import android.os.Build;
33
33
  import android.os.Bundle;
34
+ import android.os.Trace;
34
35
  import android.service.notification.StatusBarNotification;
35
36
  import androidx.annotation.NonNull;
36
37
  import androidx.concurrent.futures.CallbackToFutureAdapter;
@@ -103,198 +104,204 @@ class NotificationManager {
103
104
  */
104
105
  Callable<NotificationCompat.Builder> builderCallable =
105
106
  () -> {
106
- Boolean hasCustomSound = false;
107
- NotificationCompat.Builder builder =
108
- new NotificationCompat.Builder(getApplicationContext(), androidModel.getChannelId());
109
-
110
- // must always keep at top
111
- builder.setExtras(notificationModel.getData());
112
-
113
- builder.setDeleteIntent(
114
- ReceiverService.createIntent(
115
- ReceiverService.DELETE_INTENT,
116
- new String[] {"notification"},
117
- notificationModel.toBundle()));
118
- // Resolve the effective pressAction bundle for the content intent.
119
- // Three cases:
120
- // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
121
- // after app kill): synthesize default { id:'default', launchActivity:'default' }
122
- // so tapping the notification opens the app (defense-in-depth for paths that
123
- // bypass the TS validator).
124
- // 2. pressAction has the opt-out sentinel id: user explicitly passed
125
- // pressAction: null in JS — pass null to createIntent so no launch intent
126
- // is created (non-tappable notification).
127
- // 3. pressAction is a normal bundle: pass through unchanged.
128
- // Resolve the effective pressAction bundle for the content intent.
129
- // Three cases:
130
- // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
131
- // after app kill): synthesize default { id:'default', launchActivity:'default' }
132
- // so tapping the notification opens the app (defense-in-depth for paths that
133
- // bypass the TS validator).
134
- // 2. pressAction has the opt-out sentinel id: user explicitly passed
135
- // pressAction: null in JS — pass null to createIntent so no launch intent
136
- // is created (non-tappable notification).
137
- // 3. pressAction is a normal bundle: pass through unchanged.
138
- //
139
- // pressActionForIntent → used for creating the launch intent (or null for opt-out)
140
- // pressActionForExtras → used in the receiver intent extras (event payload);
141
- // null for cases 1 & 2 to avoid leaking synthesized defaults
142
- // or the sentinel id into the JS event.
143
- Bundle pressActionForIntent = androidModel.getPressAction();
144
- Bundle pressActionForExtras = pressActionForIntent;
145
- if (pressActionForIntent == null) {
146
- // Case 1: absent — synthesize default for launch, null for extras
147
- pressActionForIntent = new Bundle();
148
- pressActionForIntent.putString("id", "default");
149
- pressActionForIntent.putString("launchActivity", "default");
150
- // pressActionForExtras stays null: the original notification didn't have
151
- // pressAction, so the event shouldn't either.
152
- } else if (NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(
153
- pressActionForIntent.getString("id"))) {
154
- // Case 2: explicit opt-out sentinel — no launch intent, no sentinel in extras
155
- pressActionForIntent = null;
156
- pressActionForExtras = null;
157
- }
158
- // Case 3: normal pressAction — both variables point to the original bundle
159
-
160
- int targetSdkVersion =
161
- ContextHolder.getApplicationContext().getApplicationInfo().targetSdkVersion;
162
- if (targetSdkVersion >= Build.VERSION_CODES.S
163
- && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
164
- builder.setContentIntent(
165
- NotificationPendingIntent.createIntent(
166
- notificationModel.getHashCode(),
167
- pressActionForIntent,
168
- TYPE_PRESS,
169
- new String[] {"notification", "pressAction"},
170
- notificationModel.toBundle(),
171
- pressActionForExtras));
172
- } else {
173
- builder.setContentIntent(
107
+ Trace.beginSection("notifee:buildNotification");
108
+ try {
109
+ Boolean hasCustomSound = false;
110
+ NotificationCompat.Builder builder =
111
+ new NotificationCompat.Builder(
112
+ getApplicationContext(), androidModel.getChannelId());
113
+
114
+ // must always keep at top
115
+ builder.setExtras(notificationModel.getData());
116
+
117
+ builder.setDeleteIntent(
174
118
  ReceiverService.createIntent(
175
- ReceiverService.PRESS_INTENT,
176
- new String[] {"notification", "pressAction"},
177
- notificationModel.toBundle(),
178
- pressActionForIntent));
179
- }
119
+ ReceiverService.DELETE_INTENT,
120
+ new String[] {"notification"},
121
+ notificationModel.toBundle()));
122
+ // Resolve the effective pressAction bundle for the content intent.
123
+ // Three cases:
124
+ // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
125
+ // after app kill): synthesize default { id:'default', launchActivity:'default' }
126
+ // so tapping the notification opens the app (defense-in-depth for paths that
127
+ // bypass the TS validator).
128
+ // 2. pressAction has the opt-out sentinel id: user explicitly passed
129
+ // pressAction: null in JS — pass null to createIntent so no launch intent
130
+ // is created (non-tappable notification).
131
+ // 3. pressAction is a normal bundle: pass through unchanged.
132
+ // Resolve the effective pressAction bundle for the content intent.
133
+ // Three cases:
134
+ // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
135
+ // after app kill): synthesize default { id:'default', launchActivity:'default' }
136
+ // so tapping the notification opens the app (defense-in-depth for paths that
137
+ // bypass the TS validator).
138
+ // 2. pressAction has the opt-out sentinel id: user explicitly passed
139
+ // pressAction: null in JS — pass null to createIntent so no launch intent
140
+ // is created (non-tappable notification).
141
+ // 3. pressAction is a normal bundle: pass through unchanged.
142
+ //
143
+ // pressActionForIntent → used for creating the launch intent (or null for opt-out)
144
+ // pressActionForExtras → used in the receiver intent extras (event payload);
145
+ // null for cases 1 & 2 to avoid leaking synthesized defaults
146
+ // or the sentinel id into the JS event.
147
+ Bundle pressActionForIntent = androidModel.getPressAction();
148
+ Bundle pressActionForExtras = pressActionForIntent;
149
+ if (pressActionForIntent == null) {
150
+ // Case 1: absent — synthesize default for launch, null for extras
151
+ pressActionForIntent = new Bundle();
152
+ pressActionForIntent.putString("id", "default");
153
+ pressActionForIntent.putString("launchActivity", "default");
154
+ // pressActionForExtras stays null: the original notification didn't have
155
+ // pressAction, so the event shouldn't either.
156
+ } else if (NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(
157
+ pressActionForIntent.getString("id"))) {
158
+ // Case 2: explicit opt-out sentinel — no launch intent, no sentinel in extras
159
+ pressActionForIntent = null;
160
+ pressActionForExtras = null;
161
+ }
162
+ // Case 3: normal pressAction — both variables point to the original bundle
163
+
164
+ int targetSdkVersion =
165
+ ContextHolder.getApplicationContext().getApplicationInfo().targetSdkVersion;
166
+ if (targetSdkVersion >= Build.VERSION_CODES.S
167
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
168
+ builder.setContentIntent(
169
+ NotificationPendingIntent.createIntent(
170
+ notificationModel.getHashCode(),
171
+ pressActionForIntent,
172
+ TYPE_PRESS,
173
+ new String[] {"notification", "pressAction"},
174
+ notificationModel.toBundle(),
175
+ pressActionForExtras));
176
+ } else {
177
+ builder.setContentIntent(
178
+ ReceiverService.createIntent(
179
+ ReceiverService.PRESS_INTENT,
180
+ new String[] {"notification", "pressAction"},
181
+ notificationModel.toBundle(),
182
+ pressActionForIntent));
183
+ }
180
184
 
181
- if (notificationModel.getTitle() != null) {
182
- builder.setContentTitle(TextUtils.fromHtml(notificationModel.getTitle()));
183
- }
185
+ if (notificationModel.getTitle() != null) {
186
+ builder.setContentTitle(TextUtils.fromHtml(notificationModel.getTitle()));
187
+ }
184
188
 
185
- if (notificationModel.getSubTitle() != null) {
186
- builder.setSubText(TextUtils.fromHtml(notificationModel.getSubTitle()));
187
- }
189
+ if (notificationModel.getSubTitle() != null) {
190
+ builder.setSubText(TextUtils.fromHtml(notificationModel.getSubTitle()));
191
+ }
188
192
 
189
- if (notificationModel.getBody() != null) {
190
- builder.setContentText(TextUtils.fromHtml(notificationModel.getBody()));
191
- }
193
+ if (notificationModel.getBody() != null) {
194
+ builder.setContentText(TextUtils.fromHtml(notificationModel.getBody()));
195
+ }
192
196
 
193
- if (androidModel.getBadgeIconType() != null) {
194
- builder.setBadgeIconType(androidModel.getBadgeIconType());
195
- }
197
+ if (androidModel.getBadgeIconType() != null) {
198
+ builder.setBadgeIconType(androidModel.getBadgeIconType());
199
+ }
196
200
 
197
- if (androidModel.getCategory() != null) {
198
- builder.setCategory(androidModel.getCategory());
199
- }
201
+ if (androidModel.getCategory() != null) {
202
+ builder.setCategory(androidModel.getCategory());
203
+ }
200
204
 
201
- if (androidModel.getColor() != null) {
202
- builder.setColor(androidModel.getColor());
203
- }
205
+ if (androidModel.getColor() != null) {
206
+ builder.setColor(androidModel.getColor());
207
+ }
204
208
 
205
- builder.setColorized(androidModel.getColorized());
209
+ builder.setColorized(androidModel.getColorized());
206
210
 
207
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
208
- builder.setChronometerCountDown(androidModel.getChronometerCountDown());
209
- }
211
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
212
+ builder.setChronometerCountDown(androidModel.getChronometerCountDown());
213
+ }
210
214
 
211
- if (androidModel.getGroup() != null) {
212
- builder.setGroup(androidModel.getGroup());
213
- }
215
+ if (androidModel.getGroup() != null) {
216
+ builder.setGroup(androidModel.getGroup());
217
+ }
214
218
 
215
- builder.setGroupAlertBehavior(androidModel.getGroupAlertBehaviour());
216
- builder.setGroupSummary(androidModel.getGroupSummary());
219
+ builder.setGroupAlertBehavior(androidModel.getGroupAlertBehaviour());
220
+ builder.setGroupSummary(androidModel.getGroupSummary());
217
221
 
218
- if (androidModel.getInputHistory() != null) {
219
- builder.setRemoteInputHistory(androidModel.getInputHistory());
220
- }
222
+ if (androidModel.getInputHistory() != null) {
223
+ builder.setRemoteInputHistory(androidModel.getInputHistory());
224
+ }
221
225
 
222
- if (androidModel.getLights() != null) {
223
- ArrayList<Integer> lights = androidModel.getLights();
224
- builder.setLights(lights.get(0), lights.get(1), lights.get(2));
225
- }
226
+ if (androidModel.getLights() != null) {
227
+ ArrayList<Integer> lights = androidModel.getLights();
228
+ builder.setLights(lights.get(0), lights.get(1), lights.get(2));
229
+ }
226
230
 
227
- builder.setLocalOnly(androidModel.getLocalOnly());
231
+ builder.setLocalOnly(androidModel.getLocalOnly());
228
232
 
229
- if (androidModel.getNumber() != null) {
230
- builder.setNumber(androidModel.getNumber());
231
- }
233
+ if (androidModel.getNumber() != null) {
234
+ builder.setNumber(androidModel.getNumber());
235
+ }
232
236
 
233
- if (androidModel.getSound() != null) {
234
- Uri soundUri = ResourceUtils.getSoundUri(androidModel.getSound());
235
- if (soundUri != null) {
236
- hasCustomSound = true;
237
- builder.setSound(soundUri);
238
- } else {
239
- Logger.w(
240
- TAG,
241
- "Unable to retrieve sound for notification, sound was specified as: "
242
- + androidModel.getSound());
237
+ if (androidModel.getSound() != null) {
238
+ Uri soundUri = ResourceUtils.getSoundUri(androidModel.getSound());
239
+ if (soundUri != null) {
240
+ hasCustomSound = true;
241
+ builder.setSound(soundUri);
242
+ } else {
243
+ Logger.w(
244
+ TAG,
245
+ "Unable to retrieve sound for notification, sound was specified as: "
246
+ + androidModel.getSound());
247
+ }
243
248
  }
244
- }
245
249
 
246
- builder.setDefaults(androidModel.getDefaults(hasCustomSound));
247
- builder.setOngoing(androidModel.getOngoing());
248
- builder.setOnlyAlertOnce(androidModel.getOnlyAlertOnce());
249
- builder.setPriority(androidModel.getPriority());
250
+ builder.setDefaults(androidModel.getDefaults(hasCustomSound));
251
+ builder.setOngoing(androidModel.getOngoing());
252
+ builder.setOnlyAlertOnce(androidModel.getOnlyAlertOnce());
253
+ builder.setPriority(androidModel.getPriority());
250
254
 
251
- NotificationAndroidModel.AndroidProgress progress = androidModel.getProgress();
252
- if (progress != null) {
253
- builder.setProgress(
254
- progress.getMax(), progress.getCurrent(), progress.getIndeterminate());
255
- }
255
+ NotificationAndroidModel.AndroidProgress progress = androidModel.getProgress();
256
+ if (progress != null) {
257
+ builder.setProgress(
258
+ progress.getMax(), progress.getCurrent(), progress.getIndeterminate());
259
+ }
256
260
 
257
- if (androidModel.getShortcutId() != null) {
258
- builder.setShortcutId(androidModel.getShortcutId());
259
- }
261
+ if (androidModel.getShortcutId() != null) {
262
+ builder.setShortcutId(androidModel.getShortcutId());
263
+ }
260
264
 
261
- builder.setShowWhen(androidModel.getShowTimestamp());
265
+ builder.setShowWhen(androidModel.getShowTimestamp());
262
266
 
263
- Integer smallIconId = androidModel.getSmallIcon();
264
- if (smallIconId != null) {
265
- Integer smallIconLevel = androidModel.getSmallIconLevel();
266
- if (smallIconLevel != null) {
267
- builder.setSmallIcon(smallIconId, smallIconLevel);
268
- } else {
269
- builder.setSmallIcon(smallIconId);
267
+ Integer smallIconId = androidModel.getSmallIcon();
268
+ if (smallIconId != null) {
269
+ Integer smallIconLevel = androidModel.getSmallIconLevel();
270
+ if (smallIconLevel != null) {
271
+ builder.setSmallIcon(smallIconId, smallIconLevel);
272
+ } else {
273
+ builder.setSmallIcon(smallIconId);
274
+ }
270
275
  }
271
- }
272
276
 
273
- if (androidModel.getSortKey() != null) {
274
- builder.setSortKey(androidModel.getSortKey());
275
- }
277
+ if (androidModel.getSortKey() != null) {
278
+ builder.setSortKey(androidModel.getSortKey());
279
+ }
276
280
 
277
- if (androidModel.getTicker() != null) {
278
- builder.setTicker(androidModel.getTicker());
279
- }
281
+ if (androidModel.getTicker() != null) {
282
+ builder.setTicker(androidModel.getTicker());
283
+ }
280
284
 
281
- if (androidModel.getTimeoutAfter() != null) {
282
- builder.setTimeoutAfter(androidModel.getTimeoutAfter());
283
- }
285
+ if (androidModel.getTimeoutAfter() != null) {
286
+ builder.setTimeoutAfter(androidModel.getTimeoutAfter());
287
+ }
284
288
 
285
- builder.setUsesChronometer(androidModel.getShowChronometer());
289
+ builder.setUsesChronometer(androidModel.getShowChronometer());
286
290
 
287
- long[] vibrationPattern = androidModel.getVibrationPattern();
288
- if (vibrationPattern.length > 0) builder.setVibrate(vibrationPattern);
291
+ long[] vibrationPattern = androidModel.getVibrationPattern();
292
+ if (vibrationPattern.length > 0) builder.setVibrate(vibrationPattern);
289
293
 
290
- builder.setVisibility(androidModel.getVisibility());
294
+ builder.setVisibility(androidModel.getVisibility());
291
295
 
292
- long timestamp = androidModel.getTimestamp();
293
- if (timestamp > -1) builder.setWhen(timestamp);
296
+ long timestamp = androidModel.getTimestamp();
297
+ if (timestamp > -1) builder.setWhen(timestamp);
294
298
 
295
- builder.setAutoCancel(androidModel.getAutoCancel());
299
+ builder.setAutoCancel(androidModel.getAutoCancel());
296
300
 
297
- return builder;
301
+ return builder;
302
+ } finally {
303
+ Trace.endSection();
304
+ }
298
305
  };
299
306
 
300
307
  /*
@@ -536,15 +543,13 @@ class NotificationManager {
536
543
  task -> {
537
544
  if (notificationType == NOTIFICATION_TYPE_TRIGGER
538
545
  || notificationType == NOTIFICATION_TYPE_ALL) {
539
- return new ExtendedListenableFuture<Void>(
540
- NotifeeAlarmManager.cancelAllNotifications())
541
- .addOnCompleteListener(
542
- (e, result) -> {
543
- if (e == null) {
544
- WorkDataRepository.getInstance(getApplicationContext()).deleteAll();
545
- }
546
- },
547
- LISTENING_CACHED_THREAD_POOL);
546
+ // Chain the Room delete onto the alarm-manager cancel so the outer
547
+ // future — and therefore the JS Promise — only completes after Room
548
+ // has drained. Fixes upstream invertase/notifee#549.
549
+ return Futures.transformAsync(
550
+ NotifeeAlarmManager.cancelAllNotifications(),
551
+ ignored -> WorkDataRepository.getInstance(getApplicationContext()).deleteAll(),
552
+ LISTENING_CACHED_THREAD_POOL);
548
553
  }
549
554
  return Futures.immediateFuture(null);
550
555
  },
@@ -576,7 +581,8 @@ class NotificationManager {
576
581
  Logger.e(
577
582
  TAG,
578
583
  "cancelAllNotificationsWithIds -> Failed to parse id as integer "
579
- + id);
584
+ + id,
585
+ e);
580
586
  }
581
587
 
582
588
  if (integerId != null) {
@@ -605,9 +611,11 @@ class NotificationManager {
605
611
  }))
606
612
  .continueWith(
607
613
  task -> {
608
- // delete all from database
614
+ // Chain the Room delete so the outer future — and therefore the JS
615
+ // Promise — only completes after Room has drained. Fixes upstream
616
+ // invertase/notifee#549 for the per-id cancel path.
609
617
  if (notificationType != NOTIFICATION_TYPE_DISPLAYED) {
610
- WorkDataRepository.getInstance(getApplicationContext()).deleteByIds(ids);
618
+ return WorkDataRepository.getInstance(getApplicationContext()).deleteByIds(ids);
611
619
  }
612
620
  return Futures.immediateFuture(null);
613
621
  },
@@ -619,103 +627,139 @@ class NotificationManager {
619
627
  return new ExtendedListenableFuture<>(notificationBundleToBuilder(notificationModel))
620
628
  .continueWith(
621
629
  (taskResult) -> {
622
- NotificationCompat.Builder builder = taskResult;
630
+ Trace.beginSection("notifee:displayNotification");
631
+ try {
632
+ NotificationCompat.Builder builder = taskResult;
633
+
634
+ // Add the following extras for `getDisplayedNotifications()`
635
+ Bundle extrasBundle = new Bundle();
636
+ extrasBundle.putBundle(EXTRA_NOTIFEE_NOTIFICATION, notificationModel.toBundle());
637
+ if (triggerBundle != null) {
638
+ extrasBundle.putBundle(EXTRA_NOTIFEE_TRIGGER, triggerBundle);
639
+ }
640
+ builder.addExtras(extrasBundle);
623
641
 
624
- // Add the following extras for `getDisplayedNotifications()`
625
- Bundle extrasBundle = new Bundle();
626
- extrasBundle.putBundle(EXTRA_NOTIFEE_NOTIFICATION, notificationModel.toBundle());
627
- if (triggerBundle != null) {
628
- extrasBundle.putBundle(EXTRA_NOTIFEE_TRIGGER, triggerBundle);
629
- }
630
- builder.addExtras(extrasBundle);
642
+ NotificationAndroidModel androidBundle = notificationModel.getAndroid();
631
643
 
632
- // build notification
633
- Notification notification = Objects.requireNonNull(builder).build();
644
+ // Set foreground service behavior before building (only for FGS notifications).
645
+ // IMMEDIATE eliminates the 10-second display delay on Android 12+.
646
+ if (androidBundle.getAsForegroundService()) {
647
+ builder.setForegroundServiceBehavior(
648
+ androidBundle.getForegroundServiceBehavior());
649
+ }
634
650
 
635
- int hashCode = notificationModel.getHashCode();
651
+ // build notification
652
+ Notification notification = Objects.requireNonNull(builder).build();
636
653
 
637
- NotificationAndroidModel androidBundle = notificationModel.getAndroid();
638
- if (androidBundle.getLoopSound()) {
639
- notification.flags |= Notification.FLAG_INSISTENT;
640
- }
654
+ int hashCode = notificationModel.getHashCode();
655
+ if (androidBundle.getLoopSound()) {
656
+ notification.flags |= Notification.FLAG_INSISTENT;
657
+ }
641
658
 
642
- if (androidBundle.getFlags() != null && androidBundle.getFlags().length > 0) {
643
- for (int flag : androidBundle.getFlags()) {
644
- notification.flags |= flag;
659
+ if (androidBundle.getFlags() != null && androidBundle.getFlags().length > 0) {
660
+ for (int flag : androidBundle.getFlags()) {
661
+ notification.flags |= flag;
662
+ }
645
663
  }
646
- }
647
664
 
648
- if (androidBundle.getLightUpScreen()) {
649
- PowerManagerUtils.lightUpScreenIfNeeded(ContextHolder.getApplicationContext());
650
- }
665
+ if (androidBundle.getLightUpScreen()) {
666
+ PowerManagerUtils.lightUpScreenIfNeeded(ContextHolder.getApplicationContext());
667
+ }
651
668
 
652
- if (androidBundle.getAsForegroundService()) {
653
- ForegroundService.start(hashCode, notification, notificationModel.toBundle());
654
- } else {
655
- NotificationManagerCompat.from(getApplicationContext())
656
- .notify(androidBundle.getTag(), hashCode, notification);
657
- }
669
+ if (androidBundle.getAsForegroundService()) {
670
+ Trace.beginSection("notifee:startForegroundService");
671
+ try {
672
+ ForegroundService.start(hashCode, notification, notificationModel.toBundle());
673
+ } finally {
674
+ Trace.endSection();
675
+ }
676
+ } else {
677
+ NotificationManagerCompat.from(getApplicationContext())
678
+ .notify(androidBundle.getTag(), hashCode, notification);
679
+ }
658
680
 
659
- EventBus.post(
660
- new NotificationEvent(NotificationEvent.TYPE_DELIVERED, notificationModel));
681
+ EventBus.post(
682
+ new NotificationEvent(NotificationEvent.TYPE_DELIVERED, notificationModel));
661
683
 
662
- return Futures.immediateFuture(null);
684
+ return Futures.immediateFuture(null);
685
+ } finally {
686
+ Trace.endSection();
687
+ }
663
688
  },
664
689
  CACHED_THREAD_POOL);
665
690
  }
666
691
 
667
692
  static ListenableFuture<Void> createTriggerNotification(
668
693
  NotificationModel notificationModel, Bundle triggerBundle) {
669
- return LISTENING_CACHED_THREAD_POOL.submit(
670
- () -> {
671
- int triggerType = ObjectUtils.getInt(triggerBundle.get("type"));
672
- switch (triggerType) {
673
- case 0:
674
- createTimestampTriggerNotification(notificationModel, triggerBundle);
675
- break;
676
- case 1:
677
- createIntervalTriggerNotification(notificationModel, triggerBundle);
678
- break;
679
- }
694
+ int triggerType = ObjectUtils.getInt(triggerBundle.get("type"));
695
+ ListenableFuture<Void> scheduleFuture;
696
+ switch (triggerType) {
697
+ case 0:
698
+ scheduleFuture = createTimestampTriggerNotification(notificationModel, triggerBundle);
699
+ break;
700
+ case 1:
701
+ scheduleFuture = createIntervalTriggerNotification(notificationModel, triggerBundle);
702
+ break;
703
+ default:
704
+ scheduleFuture = Futures.immediateFuture(null);
705
+ break;
706
+ }
680
707
 
708
+ return Futures.transform(
709
+ scheduleFuture,
710
+ unused -> {
681
711
  EventBus.post(
682
712
  new NotificationEvent(
683
713
  NotificationEvent.TYPE_TRIGGER_NOTIFICATION_CREATED, notificationModel));
684
-
685
714
  return null;
686
- });
715
+ },
716
+ LISTENING_CACHED_THREAD_POOL);
687
717
  }
688
718
 
689
- static void createIntervalTriggerNotification(
719
+ // Returns a future that completes only after the Room insert has persisted AND
720
+ // WorkManager has enqueued the periodic work. Chaining Room-first guarantees the
721
+ // worker reads the row on its first fire even if the process is killed between
722
+ // the JS Promise resolving and WorkManager scheduling. Fixes upstream
723
+ // invertase/notifee#549 for the interval-trigger path.
724
+ static ListenableFuture<Void> createIntervalTriggerNotification(
690
725
  NotificationModel notificationModel, Bundle triggerBundle) {
691
726
  IntervalTriggerModel trigger = IntervalTriggerModel.fromBundle(triggerBundle);
692
727
  String uniqueWorkName = "trigger:" + notificationModel.getId();
693
- WorkManager workManager = WorkManager.getInstance(getApplicationContext());
694
-
695
- Data.Builder workDataBuilder =
696
- new Data.Builder()
697
- .putString(Worker.KEY_WORK_TYPE, Worker.WORK_TYPE_NOTIFICATION_TRIGGER)
698
- .putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_PERIODIC)
699
- .putString("id", notificationModel.getId());
700
-
701
- WorkDataRepository.getInstance(getApplicationContext())
702
- .insertTriggerNotification(notificationModel, triggerBundle, false);
703
-
704
- long interval = trigger.getInterval();
705
-
706
- PeriodicWorkRequest.Builder workRequestBuilder;
707
- workRequestBuilder =
708
- new PeriodicWorkRequest.Builder(Worker.class, interval, trigger.getTimeUnit())
709
- .setInitialDelay(interval, trigger.getTimeUnit());
710
-
711
- workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
712
- workRequestBuilder.addTag(uniqueWorkName);
713
- workRequestBuilder.setInputData(workDataBuilder.build());
714
- workManager.enqueueUniquePeriodicWork(
715
- uniqueWorkName, ExistingPeriodicWorkPolicy.UPDATE, workRequestBuilder.build());
728
+ Context context = getApplicationContext();
729
+
730
+ ListenableFuture<Void> insertFuture =
731
+ WorkDataRepository.insertTriggerNotification(
732
+ context, notificationModel, triggerBundle, false);
733
+
734
+ return Futures.transform(
735
+ insertFuture,
736
+ unused -> {
737
+ WorkManager workManager = WorkManager.getInstance(context);
738
+ Data.Builder workDataBuilder =
739
+ new Data.Builder()
740
+ .putString(Worker.KEY_WORK_TYPE, Worker.WORK_TYPE_NOTIFICATION_TRIGGER)
741
+ .putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_PERIODIC)
742
+ .putString("id", notificationModel.getId());
743
+
744
+ long interval = trigger.getInterval();
745
+ PeriodicWorkRequest.Builder workRequestBuilder =
746
+ new PeriodicWorkRequest.Builder(Worker.class, interval, trigger.getTimeUnit())
747
+ .setInitialDelay(interval, trigger.getTimeUnit());
748
+ workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
749
+ workRequestBuilder.addTag(uniqueWorkName);
750
+ workRequestBuilder.setInputData(workDataBuilder.build());
751
+ workManager.enqueueUniquePeriodicWork(
752
+ uniqueWorkName, ExistingPeriodicWorkPolicy.UPDATE, workRequestBuilder.build());
753
+ return null;
754
+ },
755
+ LISTENING_CACHED_THREAD_POOL);
716
756
  }
717
757
 
718
- static void createTimestampTriggerNotification(
758
+ // Returns a future that completes only after the Room insert has persisted AND
759
+ // the AlarmManager / WorkManager schedule call has run. Same rationale as the
760
+ // interval path above. Fixes upstream invertase/notifee#549 for the timestamp
761
+ // trigger path (AlarmManager by default since 9.1.12).
762
+ static ListenableFuture<Void> createTimestampTriggerNotification(
719
763
  NotificationModel notificationModel, Bundle triggerBundle) {
720
764
  TimestampTriggerModel trigger = TimestampTriggerModel.fromBundle(triggerBundle);
721
765
 
@@ -724,52 +768,56 @@ class NotificationManager {
724
768
  long delay = trigger.getDelay();
725
769
  int interval = trigger.getInterval();
726
770
 
727
- // Save in DB
728
771
  Data.Builder workDataBuilder =
729
772
  new Data.Builder()
730
773
  .putString(Worker.KEY_WORK_TYPE, Worker.WORK_TYPE_NOTIFICATION_TRIGGER)
731
774
  .putString("id", notificationModel.getId());
732
775
 
733
776
  Boolean withAlarmManager = trigger.getWithAlarmManager();
777
+ Context context = getApplicationContext();
778
+
779
+ ListenableFuture<Void> insertFuture =
780
+ WorkDataRepository.insertTriggerNotification(
781
+ context, notificationModel, triggerBundle, withAlarmManager);
782
+
783
+ return Futures.transform(
784
+ insertFuture,
785
+ unused -> {
786
+ if (withAlarmManager) {
787
+ NotifeeAlarmManager.scheduleTimestampTriggerNotification(notificationModel, trigger);
788
+ return null;
789
+ }
734
790
 
735
- WorkDataRepository.getInstance(getApplicationContext())
736
- .insertTriggerNotification(notificationModel, triggerBundle, withAlarmManager);
737
-
738
- // Schedule notification with alarm manager
739
- if (withAlarmManager) {
740
- NotifeeAlarmManager.scheduleTimestampTriggerNotification(notificationModel, trigger);
741
- return;
742
- }
743
-
744
- // Continue to schedule trigger notification with WorkManager
745
- WorkManager workManager = WorkManager.getInstance(getApplicationContext());
746
-
747
- // WorkManager - One time trigger
748
- if (interval == -1) {
749
- OneTimeWorkRequest.Builder workRequestBuilder = new OneTimeWorkRequest.Builder(Worker.class);
750
- workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
751
- workRequestBuilder.addTag(uniqueWorkName);
752
- workDataBuilder.putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_ONE_TIME);
753
- workRequestBuilder.setInputData(workDataBuilder.build());
754
- workRequestBuilder.setInitialDelay(delay, TimeUnit.SECONDS);
755
- workManager.enqueueUniqueWork(
756
- uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequestBuilder.build());
757
- } else {
758
- // WorkManager - repeat trigger
759
- PeriodicWorkRequest.Builder workRequestBuilder;
760
-
761
- workRequestBuilder =
762
- new PeriodicWorkRequest.Builder(
763
- Worker.class, trigger.getInterval(), trigger.getTimeUnit());
764
-
765
- workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
766
- workRequestBuilder.addTag(uniqueWorkName);
767
- workRequestBuilder.setInitialDelay(delay, TimeUnit.SECONDS);
768
- workDataBuilder.putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_PERIODIC);
769
- workRequestBuilder.setInputData(workDataBuilder.build());
770
- workManager.enqueueUniquePeriodicWork(
771
- uniqueWorkName, ExistingPeriodicWorkPolicy.UPDATE, workRequestBuilder.build());
772
- }
791
+ WorkManager workManager = WorkManager.getInstance(context);
792
+
793
+ // WorkManager - One time trigger
794
+ if (interval == -1) {
795
+ OneTimeWorkRequest.Builder workRequestBuilder =
796
+ new OneTimeWorkRequest.Builder(Worker.class);
797
+ workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
798
+ workRequestBuilder.addTag(uniqueWorkName);
799
+ workDataBuilder.putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_ONE_TIME);
800
+ workRequestBuilder.setInputData(workDataBuilder.build());
801
+ workRequestBuilder.setInitialDelay(delay, TimeUnit.SECONDS);
802
+ workManager.enqueueUniqueWork(
803
+ uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequestBuilder.build());
804
+ } else {
805
+ // WorkManager - repeat trigger
806
+ PeriodicWorkRequest.Builder workRequestBuilder =
807
+ new PeriodicWorkRequest.Builder(
808
+ Worker.class, trigger.getInterval(), trigger.getTimeUnit());
809
+
810
+ workRequestBuilder.addTag(Worker.WORK_TYPE_NOTIFICATION_TRIGGER);
811
+ workRequestBuilder.addTag(uniqueWorkName);
812
+ workRequestBuilder.setInitialDelay(delay, TimeUnit.SECONDS);
813
+ workDataBuilder.putString(Worker.KEY_WORK_REQUEST, Worker.WORK_REQUEST_PERIODIC);
814
+ workRequestBuilder.setInputData(workDataBuilder.build());
815
+ workManager.enqueueUniquePeriodicWork(
816
+ uniqueWorkName, ExistingPeriodicWorkPolicy.UPDATE, workRequestBuilder.build());
817
+ }
818
+ return null;
819
+ },
820
+ LISTENING_CACHED_THREAD_POOL);
773
821
  }
774
822
 
775
823
  static ListenableFuture<List<Bundle>> getDisplayedNotifications() {
@@ -957,17 +1005,51 @@ class NotificationManager {
957
1005
  new ExtendedListenableFuture<>(result)
958
1006
  .addOnCompleteListener(
959
1007
  (e2, _unused) -> {
960
- completer.set(Result.success());
961
1008
  if (e2 != null) {
962
1009
  Logger.e(TAG, "Failed to display notification", e2);
1010
+ completer.set(Result.success());
1011
+ return;
1012
+ }
1013
+ String workerRequestType = data.getString(Worker.KEY_WORK_REQUEST);
1014
+ if (workerRequestType != null
1015
+ && workerRequestType.equals(Worker.WORK_REQUEST_ONE_TIME)) {
1016
+ // DO NOT reorder — completer.set must only run after the
1017
+ // delete future completes, otherwise WorkManager may start
1018
+ // a new work instance that reads the stale row. Previously
1019
+ // completer.set fired before the delete was enqueued, leaving
1020
+ // a zombie row that reboot recovery would resurrect as a
1021
+ // ghost alarm. See #549 audit Part B, Caller #5.
1022
+ //
1023
+ // Note: CallbackToFutureAdapter.Completer.set() returns
1024
+ // false on double-set, it does NOT throw. This is an
1025
+ // androidx contract. Do not refactor under the assumption
1026
+ // that double-set is dangerous.
1027
+ Futures.addCallback(
1028
+ WorkDataRepository.getInstance(getApplicationContext())
1029
+ .deleteById(id),
1030
+ new FutureCallback<Void>() {
1031
+ @Override
1032
+ public void onSuccess(Void unused) {
1033
+ completer.set(Result.success());
1034
+ }
1035
+
1036
+ @Override
1037
+ public void onFailure(@NonNull Throwable t) {
1038
+ // Notification was already displayed; a failed
1039
+ // delete leaves an orphan row that the next
1040
+ // cancelAll or app restart will clean up. Still
1041
+ // report success so WorkManager doesn't retry
1042
+ // the already-displayed notification.
1043
+ Logger.e(
1044
+ TAG,
1045
+ "Failed to delete one-time trigger row after" + " display",
1046
+ new Exception(t));
1047
+ completer.set(Result.success());
1048
+ }
1049
+ },
1050
+ LISTENING_CACHED_THREAD_POOL);
963
1051
  } else {
964
- String workerRequestType = data.getString(Worker.KEY_WORK_REQUEST);
965
- if (workerRequestType != null
966
- && workerRequestType.equals(Worker.WORK_REQUEST_ONE_TIME)) {
967
- // delete database entry if work is a one-time request
968
- WorkDataRepository.getInstance(getApplicationContext())
969
- .deleteById(id);
970
- }
1052
+ completer.set(Result.success());
971
1053
  }
972
1054
  },
973
1055
  LISTENING_CACHED_THREAD_POOL);