react-native-notify-kit 9.5.0 → 9.6.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.
@@ -28,9 +28,12 @@ import android.os.Build;
28
28
  import android.os.Bundle;
29
29
  import androidx.annotation.NonNull;
30
30
  import androidx.annotation.Nullable;
31
+ import androidx.annotation.VisibleForTesting;
31
32
  import androidx.core.app.AlarmManagerCompat;
32
33
  import app.notifee.core.database.WorkDataEntity;
33
34
  import app.notifee.core.database.WorkDataRepository;
35
+ import app.notifee.core.event.NotificationEvent;
36
+ import app.notifee.core.model.NotificationAndroidModel;
34
37
  import app.notifee.core.model.NotificationModel;
35
38
  import app.notifee.core.model.TimestampTriggerModel;
36
39
  import app.notifee.core.utility.AlarmUtils;
@@ -44,11 +47,13 @@ import com.google.common.util.concurrent.MoreExecutors;
44
47
  import java.util.ArrayList;
45
48
  import java.util.Arrays;
46
49
  import java.util.List;
50
+ import java.util.concurrent.Executor;
47
51
  import java.util.concurrent.ExecutorService;
48
52
  import java.util.concurrent.Executors;
49
53
  import java.util.concurrent.ScheduledExecutorService;
50
54
  import java.util.concurrent.TimeUnit;
51
55
  import java.util.concurrent.TimeoutException;
56
+ import java.util.concurrent.atomic.AtomicBoolean;
52
57
 
53
58
  class NotifeeAlarmManager {
54
59
  private static final String TAG = "NotifeeAlarmManager";
@@ -57,6 +62,27 @@ class NotifeeAlarmManager {
57
62
  private static final ListeningExecutorService alarmManagerListeningExecutor =
58
63
  MoreExecutors.listeningDecorator(alarmManagerExecutor);
59
64
 
65
+ /**
66
+ * Grace period for stale non-repeating triggers discovered during reboot recovery. Within this
67
+ * window the trigger is fired once and the Room row is deleted; beyond it the row is deleted
68
+ * silently to avoid showing stale content. Fix for upstream invertase/notifee#734: on OEM devices
69
+ * (MIUI, ColorOS, EMUI, FuntouchOS) that suppress BOOT_COMPLETED, zombie rows would otherwise
70
+ * re-fire on every reboot and never be cleaned.
71
+ */
72
+ private static final long STALE_TRIGGER_GRACE_PERIOD_MS = TimeUnit.HOURS.toMillis(24);
73
+
74
+ /**
75
+ * Process-wide guard against concurrent reschedule passes. Set via {@code compareAndSet} at the
76
+ * entry of {@link #rescheduleNotifications} and cleared on every terminal path. Prevents the
77
+ * double-advancement race that would occur if {@code RebootBroadcastReceiver} (triggered by
78
+ * BOOT_COMPLETED) and {@code InitProvider} (triggered by the BOOT_COUNT cold-start recovery added
79
+ * for upstream invertase/notifee#734) both ran {@code rescheduleNotifications} in parallel: for
80
+ * past-timestamp repeating triggers, {@code setNextTimestamp} advances the anchor in-place, so
81
+ * two concurrent passes would skip a full period. Duplicate concurrent requests are logged and
82
+ * dropped — the winning pass owns the reschedule cycle to completion.
83
+ */
84
+ private static final AtomicBoolean rescheduleInProgress = new AtomicBoolean(false);
85
+
60
86
  // Scheduler used only as the timeout clock for Futures.withTimeout on Room
61
87
  // writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
62
88
  // ~10s before Android kills the process, so we cap the Room wait at 8s and
@@ -71,10 +97,15 @@ class NotifeeAlarmManager {
71
97
  * from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
72
98
  * within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
73
99
  * {@code logContext}; real failures at {@code ERROR}.
100
+ *
101
+ * <p>If {@code beforeFinish} is non-null it is executed immediately before {@code
102
+ * pendingResult.finish()} on both the success and failure paths. Callers use this hook to release
103
+ * any process-wide state they acquired before dispatching (e.g. the reschedule lock).
74
104
  */
75
105
  private static void finishReceiverWhenDone(
76
106
  @NonNull ListenableFuture<?> future,
77
107
  @Nullable BroadcastReceiver.PendingResult pendingResult,
108
+ @Nullable Runnable beforeFinish,
78
109
  @NonNull String logContext) {
79
110
  ListenableFuture<?> bounded =
80
111
  Futures.withTimeout(
@@ -84,6 +115,9 @@ class NotifeeAlarmManager {
84
115
  new FutureCallback<Object>() {
85
116
  @Override
86
117
  public void onSuccess(Object result) {
118
+ if (beforeFinish != null) {
119
+ beforeFinish.run();
120
+ }
87
121
  if (pendingResult != null) {
88
122
  pendingResult.finish();
89
123
  }
@@ -102,6 +136,9 @@ class NotifeeAlarmManager {
102
136
  } else {
103
137
  Logger.e(TAG, "Failure in " + logContext, new Exception(t));
104
138
  }
139
+ if (beforeFinish != null) {
140
+ beforeFinish.run();
141
+ }
105
142
  if (pendingResult != null) {
106
143
  pendingResult.finish();
107
144
  }
@@ -182,7 +219,7 @@ class NotifeeAlarmManager {
182
219
  // Awaits the Room write before pendingResult.finish(), bounded by the 8s
183
220
  // ANR safety timeout. Handles success, failure, and timeout uniformly.
184
221
  finishReceiverWhenDone(
185
- displayAndPersistFuture, pendingResult, "displayScheduledNotification[" + id + "]");
222
+ displayAndPersistFuture, pendingResult, null, "displayScheduledNotification[" + id + "]");
186
223
  }
187
224
 
188
225
  public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
@@ -268,21 +305,23 @@ class NotifeeAlarmManager {
268
305
  pendingIntent);
269
306
  break;
270
307
  case SET_ALARM_CLOCK:
271
- int mutabilityFlag = PendingIntent.FLAG_UPDATE_CURRENT;
272
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
273
- mutabilityFlag = PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT;
274
- }
275
-
276
- Context context = getApplicationContext();
277
- Intent launchActivityIntent =
278
- context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
279
-
308
+ // Build the "show intent" required by AlarmClockInfo — the PendingIntent Android
309
+ // fires when the user taps the alarm-clock icon rendered in the status bar while
310
+ // this alarm is pending. Reuse NotificationPendingIntent.createIntent so the tap
311
+ // funnels through the same NotificationReceiverActivity → onForegroundEvent path
312
+ // as a normal notification tap, honouring any custom pressAction.launchActivity /
313
+ // mainComponent the user configured. Building a getLaunchIntentForPackage ad-hoc
314
+ // (as the upstream PR #749 did) bypasses the pressAction fixes added in 9.1.19 /
315
+ // 9.3.0 and strands custom routing.
316
+ Bundle showIntentPressAction = buildShowIntentPressActionBundle(notificationModel);
280
317
  PendingIntent pendingLaunchIntent =
281
- PendingIntent.getActivity(
282
- context,
283
- notificationModel.getId().hashCode(),
284
- launchActivityIntent,
285
- mutabilityFlag);
318
+ NotificationPendingIntent.createIntent(
319
+ notificationModel.getHashCode(),
320
+ showIntentPressAction,
321
+ NotificationEvent.TYPE_PRESS,
322
+ new String[] {"notification"},
323
+ notificationModel.toBundle());
324
+
286
325
  AlarmManagerCompat.setAlarmClock(
287
326
  alarmManager, timestampTrigger.getTimestamp(), pendingLaunchIntent, pendingIntent);
288
327
  break;
@@ -300,6 +339,41 @@ class NotifeeAlarmManager {
300
339
  }
301
340
  }
302
341
 
342
+ /**
343
+ * Resolve the pressAction bundle used to build the AlarmClockInfo show intent for {@link
344
+ * TimestampTriggerModel.AlarmType#SET_ALARM_CLOCK}. Mirrors the three-case logic in {@code
345
+ * NotificationManager.displayNotification} so the status-bar alarm-clock icon, when tapped, goes
346
+ * through the same {@code NotificationReceiverActivity} path as a normal tap:
347
+ *
348
+ * <ol>
349
+ * <li>pressAction absent → synthesize default {@code { id:'default', launchActivity:'default'
350
+ * }} so the tap opens the app (defense-in-depth for triggers rehydrated from Room after an
351
+ * app kill, which lose their pressAction bundle).
352
+ * <li>pressAction carries the {@link NotificationPendingIntent#PRESS_ACTION_OPT_OUT_ID}
353
+ * sentinel (user passed {@code pressAction: null} in JS) → still synthesize the default.
354
+ * Unlike a content intent, the status-bar alarm-clock icon has no "non-tappable" mode —
355
+ * Android requires a non-null show intent. Opening the launcher is the least-surprising
356
+ * fallback and matches stock Clock app behaviour.
357
+ * <li>pressAction is a normal bundle → pass through unchanged so custom {@code launchActivity}
358
+ * / {@code mainComponent} routing is honoured.
359
+ * </ol>
360
+ */
361
+ @VisibleForTesting
362
+ static Bundle buildShowIntentPressActionBundle(NotificationModel notificationModel) {
363
+ NotificationAndroidModel androidModel = notificationModel.getAndroid();
364
+ Bundle pressAction = androidModel != null ? androidModel.getPressAction() : null;
365
+
366
+ if (pressAction == null
367
+ || NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(pressAction.getString("id"))) {
368
+ Bundle defaultPressAction = new Bundle();
369
+ defaultPressAction.putString("id", "default");
370
+ defaultPressAction.putString("launchActivity", "default");
371
+ return defaultPressAction;
372
+ }
373
+
374
+ return pressAction;
375
+ }
376
+
303
377
  ListenableFuture<List<WorkDataEntity>> getScheduledNotifications() {
304
378
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
305
379
  return workDataRepository.getAllWithAlarmManager(true);
@@ -359,6 +433,12 @@ class NotifeeAlarmManager {
359
433
  return Futures.immediateFuture(null);
360
434
  }
361
435
 
436
+ ListenableFuture<Void> staleResult =
437
+ handleStaleNonRepeatingTrigger(notificationModel, trigger);
438
+ if (staleResult != null) {
439
+ return staleResult;
440
+ }
441
+
362
442
  scheduleTimestampTriggerNotification(notificationModel, trigger);
363
443
  // Persist updated timestamp so next reboot starts from the correct anchor.
364
444
  return WorkDataRepository.getInstance(getApplicationContext())
@@ -376,7 +456,133 @@ class NotifeeAlarmManager {
376
456
  }
377
457
  }
378
458
 
459
+ /**
460
+ * Handles a stale non-repeating TIMESTAMP trigger found during reboot recovery. A trigger is
461
+ * "stale" when its {@code repeatFrequency} is {@code null} (one-shot) and its {@code timestamp}
462
+ * has already passed. Returns a {@link ListenableFuture} describing the stale-handling action, or
463
+ * {@code null} if the trigger is not stale and the caller should proceed with normal re-arming.
464
+ *
465
+ * <p>Within {@link #STALE_TRIGGER_GRACE_PERIOD_MS} of the original fire time the notification is
466
+ * fired once (late) and the Room row is deleted; beyond the grace period the row is deleted
467
+ * silently. In both cases the row is removed, preventing the zombie re-fire loop described in
468
+ * upstream invertase/notifee#734.
469
+ *
470
+ * <p>Package-private (not {@code private}) so that {@code NotifeeAlarmManagerHandleStaleTest} in
471
+ * {@code src/test/java/app/notifee/core/} can exercise the resilient display → delete chain via
472
+ * the {@code (..., Executor)} overload with {@code MoreExecutors.directExecutor()}.
473
+ */
474
+ @Nullable
475
+ static ListenableFuture<Void> handleStaleNonRepeatingTrigger(
476
+ NotificationModel notificationModel, TimestampTriggerModel trigger) {
477
+ return handleStaleNonRepeatingTrigger(
478
+ notificationModel, trigger, alarmManagerListeningExecutor);
479
+ }
480
+
481
+ /**
482
+ * Testable overload of {@link #handleStaleNonRepeatingTrigger(NotificationModel,
483
+ * TimestampTriggerModel)} that accepts the {@link Executor} used for the resilient display →
484
+ * delete chain. Production callers use the no-executor overload which delegates with the
485
+ * cached-thread-pool listening executor. Unit tests pass {@code
486
+ * com.google.common.util.concurrent.MoreExecutors.directExecutor()} so that Mockito's
487
+ * thread-local {@code mockStatic} intercepts fire on the calling thread instead of a worker
488
+ * thread where the stubs are inactive.
489
+ */
490
+ @VisibleForTesting
491
+ @Nullable
492
+ static ListenableFuture<Void> handleStaleNonRepeatingTrigger(
493
+ NotificationModel notificationModel, TimestampTriggerModel trigger, Executor executor) {
494
+ if (trigger.getRepeatFrequency() != null) {
495
+ return null;
496
+ }
497
+ long nowMs = System.currentTimeMillis();
498
+ long triggerTs = trigger.getTimestamp();
499
+ if (triggerTs >= nowMs) {
500
+ return null;
501
+ }
502
+
503
+ String id = notificationModel.getId();
504
+ WorkDataRepository workRepo = WorkDataRepository.getInstance(getApplicationContext());
505
+ long stalenessMs = nowMs - triggerTs;
506
+
507
+ if (stalenessMs > STALE_TRIGGER_GRACE_PERIOD_MS) {
508
+ Logger.i(
509
+ TAG,
510
+ "Deleting stale non-repeating trigger (age "
511
+ + stalenessMs
512
+ + "ms > "
513
+ + STALE_TRIGGER_GRACE_PERIOD_MS
514
+ + "ms grace period): "
515
+ + id);
516
+ return workRepo.deleteById(id);
517
+ }
518
+
519
+ Logger.i(
520
+ TAG,
521
+ "Firing stale non-repeating trigger once within grace period (age "
522
+ + stalenessMs
523
+ + "ms): "
524
+ + id);
525
+
526
+ // Critical: even if the late-fire fails — because the target notification
527
+ // channel has been deleted between scheduling and recovery, because the
528
+ // serialized NotificationModel was written by an older library version
529
+ // whose shape differs, or because displayNotification throws for any other
530
+ // reason — we MUST still delete the Room row. Otherwise the zombie re-fire
531
+ // loop this helper is supposed to break persists across future reboots,
532
+ // exactly the scenario #734 is about. The within-grace path promises
533
+ // "attempt to fire, always clean"; the fire-once is best-effort, the delete
534
+ // is the correctness guarantee. Discovered via the Step 6 smoke dry-run,
535
+ // which surfaced a NullPointerException in NotificationAndroidModel.getChannelId
536
+ // and observed the chained deleteById never running.
537
+ //
538
+ // Post-Step-6 code review (Step 7) hardened the chain against two further
539
+ // failure modes the original catchingAsync missed:
540
+ //
541
+ // 1. Sync throws from NotificationManager.displayNotification (e.g. an
542
+ // NPE from NotificationModel.getAndroid() before the work Callable is
543
+ // even constructed) would bypass catchingAsync entirely, because the
544
+ // primary input future to catchingAsync did not yet exist. Wrapping
545
+ // the call in Futures.submitAsync converts any sync throw from the
546
+ // AsyncCallable into a failed future that catchingAsync can observe.
547
+ //
548
+ // 2. Throwable.class was too broad — it swallowed Error subclasses
549
+ // including OutOfMemoryError, VirtualMachineError, LinkageError,
550
+ // AssertionError. On a memory-pressured cold boot (the exact target
551
+ // scenario of #734) an OOM inside NotificationCompat.Builder.build()
552
+ // would be silently absorbed and the handler would proceed to a Room
553
+ // write while the JVM was seconds from termination. Narrowed to
554
+ // Exception.class so Errors propagate as batch failures; the
555
+ // per-entity catch in rescheduleNotifications leaves the row in Room
556
+ // for a genuine retry on the next reboot pass.
557
+ ListenableFuture<Void> displayAttempt =
558
+ Futures.submitAsync(
559
+ () -> NotificationManager.displayNotification(notificationModel, null), executor);
560
+
561
+ ListenableFuture<Void> resilientDisplay =
562
+ Futures.catchingAsync(
563
+ displayAttempt,
564
+ Exception.class,
565
+ t -> {
566
+ Logger.w(
567
+ TAG, "Late-fire of stale trigger " + id + " failed, proceeding to delete row", t);
568
+ return Futures.immediateFuture(null);
569
+ },
570
+ executor);
571
+
572
+ return Futures.transformAsync(resilientDisplay, ignored -> workRepo.deleteById(id), executor);
573
+ }
574
+
379
575
  void rescheduleNotifications(@Nullable BroadcastReceiver.PendingResult pendingResult) {
576
+ if (!rescheduleInProgress.compareAndSet(false, true)) {
577
+ Logger.i(TAG, "Reschedule already in progress, skipping duplicate request");
578
+ if (pendingResult != null) {
579
+ pendingResult.finish();
580
+ }
581
+ return;
582
+ }
583
+
584
+ final Runnable releaseLock = () -> rescheduleInProgress.set(false);
585
+
380
586
  Logger.d(TAG, "Reschedule Notifications on reboot");
381
587
  Futures.addCallback(
382
588
  getScheduledNotifications(),
@@ -390,6 +596,7 @@ class NotifeeAlarmManager {
390
596
  + " recurring alarms");
391
597
 
392
598
  if (workDataEntities == null || workDataEntities.isEmpty()) {
599
+ releaseLock.run();
393
600
  if (pendingResult != null) {
394
601
  pendingResult.finish();
395
602
  }
@@ -415,14 +622,18 @@ class NotifeeAlarmManager {
415
622
  // Awaits all per-entity update futures before finishing the boot
416
623
  // receiver, bounded by the 8s ANR safety timeout. Any not-yet-
417
624
  // persisted next-fire anchors left behind on timeout will catch up
418
- // on the next alarm fire.
625
+ // on the next alarm fire. The releaseLock runnable is invoked from
626
+ // inside finishReceiverWhenDone's terminal callbacks (success,
627
+ // failure, or timeout), guaranteeing the CAS flag is cleared
628
+ // exactly once on every code path.
419
629
  ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
420
- finishReceiverWhenDone(combined, pendingResult, "rescheduleNotifications");
630
+ finishReceiverWhenDone(combined, pendingResult, releaseLock, "rescheduleNotifications");
421
631
  }
422
632
 
423
633
  @Override
424
634
  public void onFailure(@NonNull Throwable t) {
425
635
  Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
636
+ releaseLock.run();
426
637
  if (pendingResult != null) {
427
638
  pendingResult.finish();
428
639
  }
@@ -29,14 +29,25 @@ import android.content.Intent;
29
29
  * display chain completes. This is critical on Android 14+ when the app is killed.
30
30
  */
31
31
  public class NotificationAlarmReceiver extends BroadcastReceiver {
32
+ private static final String TAG = "NotificationAlarmReceiver";
33
+
32
34
  @Override
33
35
  public void onReceive(Context context, Intent intent) {
34
36
  PendingResult pendingResult = goAsync();
35
-
36
- if (ContextHolder.getApplicationContext() == null) {
37
- ContextHolder.setApplicationContext(context.getApplicationContext());
37
+ // See RebootBroadcastReceiver for the rationale behind this guard.
38
+ boolean asyncHandoffSucceeded = false;
39
+ try {
40
+ if (ContextHolder.getApplicationContext() == null) {
41
+ ContextHolder.setApplicationContext(context.getApplicationContext());
42
+ }
43
+ NotifeeAlarmManager.displayScheduledNotification(intent.getExtras(), pendingResult);
44
+ asyncHandoffSucceeded = true;
45
+ } catch (Throwable t) {
46
+ Logger.e(TAG, "Failed to display scheduled notification", t);
47
+ } finally {
48
+ if (!asyncHandoffSucceeded && pendingResult != null) {
49
+ pendingResult.finish();
50
+ }
38
51
  }
39
-
40
- NotifeeAlarmManager.displayScheduledNotification(intent.getExtras(), pendingResult);
41
52
  }
42
53
  }
@@ -22,6 +22,15 @@ import android.content.SharedPreferences;
22
22
 
23
23
  class Preferences {
24
24
  private static final String PREFERENCES_FILE = "app.notifee.core";
25
+
26
+ /**
27
+ * SharedPreferences key storing the value of {@code Settings.Global.BOOT_COUNT} observed on the
28
+ * most recent successful app init. Compared against the current {@code BOOT_COUNT} to detect a
29
+ * reboot since the last app run — the core mechanism of the OEM cold-start recovery added for
30
+ * upstream invertase/notifee#734.
31
+ */
32
+ static final String LAST_KNOWN_BOOT_COUNT_KEY = "notifee_last_known_boot_count";
33
+
25
34
  private static Preferences sharedInstance = new Preferences();
26
35
  private SharedPreferences preferences;
27
36
 
@@ -20,20 +20,35 @@ package app.notifee.core;
20
20
  import android.content.BroadcastReceiver;
21
21
  import android.content.Context;
22
22
  import android.content.Intent;
23
- import android.util.Log;
24
23
 
25
24
  /*
26
25
  * This is invoked when the phone restarts to ensure that all notifications created by the alarm manager
27
26
  * are rescheduled correctly, as Android removes all scheduled alarms when the phone shuts down.
28
27
  */
29
28
  public class RebootBroadcastReceiver extends BroadcastReceiver {
29
+ private static final String TAG = "RebootReceiver";
30
+
30
31
  @Override
31
32
  public void onReceive(Context context, Intent intent) {
32
33
  PendingResult pendingResult = goAsync();
33
- Log.i("RebootReceiver", "Received reboot event");
34
- if (ContextHolder.getApplicationContext() == null) {
35
- ContextHolder.setApplicationContext(context.getApplicationContext());
34
+ // Tracks whether the synchronous section successfully handed off to the
35
+ // async reschedule path. If not, the finally block must call finish() to
36
+ // avoid leaving the broadcast unterminated — Android will otherwise kill
37
+ // the process after ~10s and race subsequent reboot broadcasts.
38
+ boolean asyncHandoffSucceeded = false;
39
+ try {
40
+ Logger.i(TAG, "Received reboot event");
41
+ if (ContextHolder.getApplicationContext() == null) {
42
+ ContextHolder.setApplicationContext(context.getApplicationContext());
43
+ }
44
+ new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
45
+ asyncHandoffSucceeded = true;
46
+ } catch (Throwable t) {
47
+ Logger.e(TAG, "Failed to reschedule notifications after reboot", t);
48
+ } finally {
49
+ if (!asyncHandoffSucceeded && pendingResult != null) {
50
+ pendingResult.finish();
51
+ }
36
52
  }
37
- new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
38
53
  }
39
54
  }
@@ -204,8 +204,9 @@ class HeadlessTask {
204
204
  private val params: WritableMap
205
205
 
206
206
  init {
207
- params.putInt("taskId", taskId)
208
- this.params = params
207
+ val copied = params.copy()
208
+ copied.putInt("taskId", taskId)
209
+ this.params = copied
209
210
  }
210
211
 
211
212
  val taskConfig: HeadlessJsTaskConfig