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
@@ -26,6 +26,7 @@ import android.content.Context;
26
26
  import android.content.Intent;
27
27
  import android.os.Build;
28
28
  import android.os.Bundle;
29
+ import androidx.annotation.NonNull;
29
30
  import androidx.annotation.Nullable;
30
31
  import androidx.core.app.AlarmManagerCompat;
31
32
  import app.notifee.core.database.WorkDataEntity;
@@ -40,10 +41,14 @@ import com.google.common.util.concurrent.Futures;
40
41
  import com.google.common.util.concurrent.ListenableFuture;
41
42
  import com.google.common.util.concurrent.ListeningExecutorService;
42
43
  import com.google.common.util.concurrent.MoreExecutors;
44
+ import java.util.ArrayList;
43
45
  import java.util.Arrays;
44
46
  import java.util.List;
45
47
  import java.util.concurrent.ExecutorService;
46
48
  import java.util.concurrent.Executors;
49
+ import java.util.concurrent.ScheduledExecutorService;
50
+ import java.util.concurrent.TimeUnit;
51
+ import java.util.concurrent.TimeoutException;
47
52
 
48
53
  class NotifeeAlarmManager {
49
54
  private static final String TAG = "NotifeeAlarmManager";
@@ -52,6 +57,59 @@ class NotifeeAlarmManager {
52
57
  private static final ListeningExecutorService alarmManagerListeningExecutor =
53
58
  MoreExecutors.listeningDecorator(alarmManagerExecutor);
54
59
 
60
+ // Scheduler used only as the timeout clock for Futures.withTimeout on Room
61
+ // writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
62
+ // ~10s before Android kills the process, so we cap the Room wait at 8s and
63
+ // call pendingResult.finish() anyway on timeout to avoid ANRs.
64
+ private static final ScheduledExecutorService TIMEOUT_SCHEDULER =
65
+ Executors.newSingleThreadScheduledExecutor();
66
+ private static final long RECEIVER_WRITE_TIMEOUT_SECONDS = 8;
67
+
68
+ /**
69
+ * Wraps {@code future} in a {@link Futures#withTimeout} safety net and arranges for {@code
70
+ * pendingResult.finish()} to be called exactly once — on success, on failure, or on timeout. Use
71
+ * from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
72
+ * within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
73
+ * {@code logContext}; real failures at {@code ERROR}.
74
+ */
75
+ private static void finishReceiverWhenDone(
76
+ @NonNull ListenableFuture<?> future,
77
+ @Nullable BroadcastReceiver.PendingResult pendingResult,
78
+ @NonNull String logContext) {
79
+ ListenableFuture<?> bounded =
80
+ Futures.withTimeout(
81
+ future, RECEIVER_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS, TIMEOUT_SCHEDULER);
82
+ Futures.addCallback(
83
+ bounded,
84
+ new FutureCallback<Object>() {
85
+ @Override
86
+ public void onSuccess(Object result) {
87
+ if (pendingResult != null) {
88
+ pendingResult.finish();
89
+ }
90
+ }
91
+
92
+ @Override
93
+ public void onFailure(@NonNull Throwable t) {
94
+ if (t instanceof TimeoutException) {
95
+ Logger.w(
96
+ TAG,
97
+ "Room write for "
98
+ + logContext
99
+ + " did not complete within "
100
+ + RECEIVER_WRITE_TIMEOUT_SECONDS
101
+ + "s; finishing BroadcastReceiver anyway to avoid ANR");
102
+ } else {
103
+ Logger.e(TAG, "Failure in " + logContext, new Exception(t));
104
+ }
105
+ if (pendingResult != null) {
106
+ pendingResult.finish();
107
+ }
108
+ }
109
+ },
110
+ alarmManagerListeningExecutor);
111
+ }
112
+
55
113
  static void displayScheduledNotification(
56
114
  Bundle alarmManagerNotification, @Nullable BroadcastReceiver.PendingResult pendingResult) {
57
115
  if (alarmManagerNotification == null) {
@@ -71,72 +129,60 @@ class NotifeeAlarmManager {
71
129
 
72
130
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
73
131
 
74
- ListenableFuture<?> displayFuture =
75
- new ExtendedListenableFuture<>(workDataRepository.getWorkDataById(id))
76
- .continueWith(
77
- workDataEntity -> {
78
- Bundle notificationBundle;
79
-
80
- Bundle triggerBundle;
81
-
82
- if (workDataEntity == null
83
- || workDataEntity.getNotification() == null
84
- || workDataEntity.getTrigger() == null) {
85
- // check if notification bundle is stored with Work Manager
86
- Logger.w(
87
- TAG,
88
- "Attempted to handle doScheduledWork but no notification data was found.");
89
- return Futures.immediateFuture(null);
90
- } else {
91
- triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
92
- notificationBundle =
93
- ObjectUtils.bytesToBundle(workDataEntity.getNotification());
94
- }
95
-
96
- NotificationModel notificationModel =
97
- NotificationModel.fromBundle(notificationBundle);
98
-
99
- return new ExtendedListenableFuture<>(
100
- NotificationManager.displayNotification(notificationModel, triggerBundle))
101
- .continueWith(
102
- voidDisplayedNotification -> {
103
- if (triggerBundle.containsKey("repeatFrequency")
104
- && ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
105
- TimestampTriggerModel trigger =
106
- TimestampTriggerModel.fromBundle(triggerBundle);
107
- // scheduleTimestampTriggerNotification() calls setNextTimestamp()
108
- // internally, so we must NOT call it here to avoid double-advancing
109
- scheduleTimestampTriggerNotification(notificationModel, trigger);
110
- WorkDataRepository.getInstance(getApplicationContext())
111
- .update(
112
- new WorkDataEntity(
113
- id,
114
- workDataEntity.getNotification(),
115
- ObjectUtils.bundleToBytes(trigger.toBundle()),
116
- true));
117
- } else {
118
- // not repeating, delete database entry if work is a one-time request
119
- WorkDataRepository.getInstance(getApplicationContext())
120
- .deleteById(id);
121
- }
122
- return Futures.immediateFuture(null);
123
- },
124
- alarmManagerExecutor);
125
- },
126
- alarmManagerExecutor)
127
- .addOnCompleteListener(
128
- (e, result) -> {
129
- try {
130
- if (e != null) {
131
- Logger.e(TAG, "Failed to display notification", e);
132
- }
133
- } finally {
134
- if (pendingResult != null) {
135
- pendingResult.finish();
132
+ // Chain: read → display → persist (update for repeat / delete for one-shot).
133
+ // The final Room write is awaited before pendingResult.finish() so the
134
+ // BroadcastReceiver only tells Android "I'm done" once the next-fire anchor
135
+ // (or the deletion) has actually landed in Room. Without this, process death
136
+ // between finish() and the enqueued write could lose the updated timestamp
137
+ // and cause the same alarm to fire again on the next reboot. See #549 audit
138
+ // callers #6 and #7.
139
+ ListenableFuture<Void> displayAndPersistFuture =
140
+ Futures.transformAsync(
141
+ workDataRepository.getWorkDataById(id),
142
+ workDataEntity -> {
143
+ if (workDataEntity == null
144
+ || workDataEntity.getNotification() == null
145
+ || workDataEntity.getTrigger() == null) {
146
+ Logger.w(
147
+ TAG, "Attempted to handle doScheduledWork but no notification data was found.");
148
+ return Futures.immediateFuture(null);
149
+ }
150
+
151
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
152
+ Bundle notificationBundle =
153
+ ObjectUtils.bytesToBundle(workDataEntity.getNotification());
154
+ NotificationModel notificationModel =
155
+ NotificationModel.fromBundle(notificationBundle);
156
+
157
+ return Futures.transformAsync(
158
+ NotificationManager.displayNotification(notificationModel, triggerBundle),
159
+ voidDisplayedNotification -> {
160
+ if (triggerBundle.containsKey("repeatFrequency")
161
+ && ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
162
+ TimestampTriggerModel trigger =
163
+ TimestampTriggerModel.fromBundle(triggerBundle);
164
+ // scheduleTimestampTriggerNotification() calls setNextTimestamp()
165
+ // internally, so we must NOT call it here to avoid double-advancing
166
+ scheduleTimestampTriggerNotification(notificationModel, trigger);
167
+ return WorkDataRepository.getInstance(getApplicationContext())
168
+ .update(
169
+ new WorkDataEntity(
170
+ id,
171
+ workDataEntity.getNotification(),
172
+ ObjectUtils.bundleToBytes(trigger.toBundle()),
173
+ true));
136
174
  }
137
- }
138
- },
139
- alarmManagerExecutor);
175
+ // not repeating, delete database entry if work is a one-time request
176
+ return WorkDataRepository.getInstance(getApplicationContext()).deleteById(id);
177
+ },
178
+ alarmManagerExecutor);
179
+ },
180
+ alarmManagerExecutor);
181
+
182
+ // Awaits the Room write before pendingResult.finish(), bounded by the 8s
183
+ // ANR safety timeout. Handles success, failure, and timeout uniformly.
184
+ finishReceiverWhenDone(
185
+ displayAndPersistFuture, pendingResult, "displayScheduledNotification[" + id + "]");
140
186
  }
141
187
 
142
188
  public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
@@ -283,10 +329,18 @@ class NotifeeAlarmManager {
283
329
  alarmManagerListeningExecutor);
284
330
  }
285
331
 
286
- /* On reboot, reschedule trigger notifications created via alarm manager */
287
- void rescheduleNotification(WorkDataEntity workDataEntity) {
332
+ /**
333
+ * On reboot, reschedule one trigger notification created via alarm manager.
334
+ *
335
+ * <p>Returns a future that completes when Room has persisted the updated next-fire anchor. The
336
+ * caller MUST await this future before calling {@code pendingResult.finish()} — otherwise Android
337
+ * may kill the boot receiver's process before Room has drained, and the next reboot will
338
+ * reschedule from the stale anchor. This bug is NOT in upstream invertase/notifee#549 and was
339
+ * surfaced only by the pre-fix-549-audit.md read-only caller audit (Caller #8).
340
+ */
341
+ ListenableFuture<Void> rescheduleNotification(WorkDataEntity workDataEntity) {
288
342
  if (workDataEntity.getNotification() == null || workDataEntity.getTrigger() == null) {
289
- return;
343
+ return Futures.immediateFuture(null);
290
344
  }
291
345
 
292
346
  byte[] notificationBytes = workDataEntity.getNotification();
@@ -302,22 +356,23 @@ class NotifeeAlarmManager {
302
356
  case 0:
303
357
  TimestampTriggerModel trigger = TimestampTriggerModel.fromBundle(triggerBundle);
304
358
  if (!trigger.getWithAlarmManager()) {
305
- return;
359
+ return Futures.immediateFuture(null);
306
360
  }
307
361
 
308
362
  scheduleTimestampTriggerNotification(notificationModel, trigger);
309
- // Persist updated timestamp so next reboot starts from the correct anchor
310
- WorkDataRepository.getInstance(getApplicationContext())
363
+ // Persist updated timestamp so next reboot starts from the correct anchor.
364
+ return WorkDataRepository.getInstance(getApplicationContext())
311
365
  .update(
312
366
  new WorkDataEntity(
313
367
  workDataEntity.getId(),
314
368
  workDataEntity.getNotification(),
315
369
  ObjectUtils.bundleToBytes(trigger.toBundle()),
316
370
  workDataEntity.getWithAlarmManager()));
317
- break;
318
371
  case 1:
319
372
  // TODO: support interval triggers with alarm manager
320
- break;
373
+ return Futures.immediateFuture(null);
374
+ default:
375
+ return Futures.immediateFuture(null);
321
376
  }
322
377
  }
323
378
 
@@ -328,19 +383,45 @@ class NotifeeAlarmManager {
328
383
  new FutureCallback<List<WorkDataEntity>>() {
329
384
  @Override
330
385
  public void onSuccess(List<WorkDataEntity> workDataEntities) {
331
- try {
332
- for (WorkDataEntity workDataEntity : workDataEntities) {
333
- rescheduleNotification(workDataEntity);
334
- }
335
- } finally {
386
+ Logger.d(
387
+ TAG,
388
+ "Reschedule starting for "
389
+ + (workDataEntities != null ? workDataEntities.size() : 0)
390
+ + " recurring alarms");
391
+
392
+ if (workDataEntities == null || workDataEntities.isEmpty()) {
336
393
  if (pendingResult != null) {
337
394
  pendingResult.finish();
338
395
  }
396
+ return;
339
397
  }
398
+
399
+ List<ListenableFuture<Void>> updateFutures = new ArrayList<>(workDataEntities.size());
400
+ for (WorkDataEntity workDataEntity : workDataEntities) {
401
+ try {
402
+ updateFutures.add(rescheduleNotification(workDataEntity));
403
+ } catch (Throwable t) {
404
+ // A single bad entity must not prevent the rest of the batch
405
+ // from being rescheduled — log and continue.
406
+ Logger.w(
407
+ TAG,
408
+ "Failed to reschedule entity "
409
+ + workDataEntity.getId()
410
+ + ": "
411
+ + t.getMessage());
412
+ }
413
+ }
414
+
415
+ // Awaits all per-entity update futures before finishing the boot
416
+ // receiver, bounded by the 8s ANR safety timeout. Any not-yet-
417
+ // persisted next-fire anchors left behind on timeout will catch up
418
+ // on the next alarm fire.
419
+ ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
420
+ finishReceiverWhenDone(combined, pendingResult, "rescheduleNotifications");
340
421
  }
341
422
 
342
423
  @Override
343
- public void onFailure(Throwable t) {
424
+ public void onFailure(@NonNull Throwable t) {
344
425
  Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
345
426
  if (pendingResult != null) {
346
427
  pendingResult.finish();