react-native-notify-kit 9.4.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.
Files changed (27) hide show
  1. package/README.md +200 -5
  2. package/android/build.gradle +1 -0
  3. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  4. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
  5. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  7. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  8. package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
  9. package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
  10. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +385 -93
  11. package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
  12. package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
  13. package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
  14. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
  15. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  16. package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
  17. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
  18. package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
  19. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
  20. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
  21. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  22. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
  23. package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +1 -1
  27. package/src/version.ts +1 -1
@@ -26,10 +26,14 @@ 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;
31
+ import androidx.annotation.VisibleForTesting;
30
32
  import androidx.core.app.AlarmManagerCompat;
31
33
  import app.notifee.core.database.WorkDataEntity;
32
34
  import app.notifee.core.database.WorkDataRepository;
35
+ import app.notifee.core.event.NotificationEvent;
36
+ import app.notifee.core.model.NotificationAndroidModel;
33
37
  import app.notifee.core.model.NotificationModel;
34
38
  import app.notifee.core.model.TimestampTriggerModel;
35
39
  import app.notifee.core.utility.AlarmUtils;
@@ -40,10 +44,16 @@ import com.google.common.util.concurrent.Futures;
40
44
  import com.google.common.util.concurrent.ListenableFuture;
41
45
  import com.google.common.util.concurrent.ListeningExecutorService;
42
46
  import com.google.common.util.concurrent.MoreExecutors;
47
+ import java.util.ArrayList;
43
48
  import java.util.Arrays;
44
49
  import java.util.List;
50
+ import java.util.concurrent.Executor;
45
51
  import java.util.concurrent.ExecutorService;
46
52
  import java.util.concurrent.Executors;
53
+ import java.util.concurrent.ScheduledExecutorService;
54
+ import java.util.concurrent.TimeUnit;
55
+ import java.util.concurrent.TimeoutException;
56
+ import java.util.concurrent.atomic.AtomicBoolean;
47
57
 
48
58
  class NotifeeAlarmManager {
49
59
  private static final String TAG = "NotifeeAlarmManager";
@@ -52,6 +62,91 @@ class NotifeeAlarmManager {
52
62
  private static final ListeningExecutorService alarmManagerListeningExecutor =
53
63
  MoreExecutors.listeningDecorator(alarmManagerExecutor);
54
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
+
86
+ // Scheduler used only as the timeout clock for Futures.withTimeout on Room
87
+ // writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
88
+ // ~10s before Android kills the process, so we cap the Room wait at 8s and
89
+ // call pendingResult.finish() anyway on timeout to avoid ANRs.
90
+ private static final ScheduledExecutorService TIMEOUT_SCHEDULER =
91
+ Executors.newSingleThreadScheduledExecutor();
92
+ private static final long RECEIVER_WRITE_TIMEOUT_SECONDS = 8;
93
+
94
+ /**
95
+ * Wraps {@code future} in a {@link Futures#withTimeout} safety net and arranges for {@code
96
+ * pendingResult.finish()} to be called exactly once — on success, on failure, or on timeout. Use
97
+ * from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
98
+ * within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
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).
104
+ */
105
+ private static void finishReceiverWhenDone(
106
+ @NonNull ListenableFuture<?> future,
107
+ @Nullable BroadcastReceiver.PendingResult pendingResult,
108
+ @Nullable Runnable beforeFinish,
109
+ @NonNull String logContext) {
110
+ ListenableFuture<?> bounded =
111
+ Futures.withTimeout(
112
+ future, RECEIVER_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS, TIMEOUT_SCHEDULER);
113
+ Futures.addCallback(
114
+ bounded,
115
+ new FutureCallback<Object>() {
116
+ @Override
117
+ public void onSuccess(Object result) {
118
+ if (beforeFinish != null) {
119
+ beforeFinish.run();
120
+ }
121
+ if (pendingResult != null) {
122
+ pendingResult.finish();
123
+ }
124
+ }
125
+
126
+ @Override
127
+ public void onFailure(@NonNull Throwable t) {
128
+ if (t instanceof TimeoutException) {
129
+ Logger.w(
130
+ TAG,
131
+ "Room write for "
132
+ + logContext
133
+ + " did not complete within "
134
+ + RECEIVER_WRITE_TIMEOUT_SECONDS
135
+ + "s; finishing BroadcastReceiver anyway to avoid ANR");
136
+ } else {
137
+ Logger.e(TAG, "Failure in " + logContext, new Exception(t));
138
+ }
139
+ if (beforeFinish != null) {
140
+ beforeFinish.run();
141
+ }
142
+ if (pendingResult != null) {
143
+ pendingResult.finish();
144
+ }
145
+ }
146
+ },
147
+ alarmManagerListeningExecutor);
148
+ }
149
+
55
150
  static void displayScheduledNotification(
56
151
  Bundle alarmManagerNotification, @Nullable BroadcastReceiver.PendingResult pendingResult) {
57
152
  if (alarmManagerNotification == null) {
@@ -71,72 +166,60 @@ class NotifeeAlarmManager {
71
166
 
72
167
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
73
168
 
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();
169
+ // Chain: read → display → persist (update for repeat / delete for one-shot).
170
+ // The final Room write is awaited before pendingResult.finish() so the
171
+ // BroadcastReceiver only tells Android "I'm done" once the next-fire anchor
172
+ // (or the deletion) has actually landed in Room. Without this, process death
173
+ // between finish() and the enqueued write could lose the updated timestamp
174
+ // and cause the same alarm to fire again on the next reboot. See #549 audit
175
+ // callers #6 and #7.
176
+ ListenableFuture<Void> displayAndPersistFuture =
177
+ Futures.transformAsync(
178
+ workDataRepository.getWorkDataById(id),
179
+ workDataEntity -> {
180
+ if (workDataEntity == null
181
+ || workDataEntity.getNotification() == null
182
+ || workDataEntity.getTrigger() == null) {
183
+ Logger.w(
184
+ TAG, "Attempted to handle doScheduledWork but no notification data was found.");
185
+ return Futures.immediateFuture(null);
186
+ }
187
+
188
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
189
+ Bundle notificationBundle =
190
+ ObjectUtils.bytesToBundle(workDataEntity.getNotification());
191
+ NotificationModel notificationModel =
192
+ NotificationModel.fromBundle(notificationBundle);
193
+
194
+ return Futures.transformAsync(
195
+ NotificationManager.displayNotification(notificationModel, triggerBundle),
196
+ voidDisplayedNotification -> {
197
+ if (triggerBundle.containsKey("repeatFrequency")
198
+ && ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
199
+ TimestampTriggerModel trigger =
200
+ TimestampTriggerModel.fromBundle(triggerBundle);
201
+ // scheduleTimestampTriggerNotification() calls setNextTimestamp()
202
+ // internally, so we must NOT call it here to avoid double-advancing
203
+ scheduleTimestampTriggerNotification(notificationModel, trigger);
204
+ return WorkDataRepository.getInstance(getApplicationContext())
205
+ .update(
206
+ new WorkDataEntity(
207
+ id,
208
+ workDataEntity.getNotification(),
209
+ ObjectUtils.bundleToBytes(trigger.toBundle()),
210
+ true));
136
211
  }
137
- }
138
- },
139
- alarmManagerExecutor);
212
+ // not repeating, delete database entry if work is a one-time request
213
+ return WorkDataRepository.getInstance(getApplicationContext()).deleteById(id);
214
+ },
215
+ alarmManagerExecutor);
216
+ },
217
+ alarmManagerExecutor);
218
+
219
+ // Awaits the Room write before pendingResult.finish(), bounded by the 8s
220
+ // ANR safety timeout. Handles success, failure, and timeout uniformly.
221
+ finishReceiverWhenDone(
222
+ displayAndPersistFuture, pendingResult, null, "displayScheduledNotification[" + id + "]");
140
223
  }
141
224
 
142
225
  public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
@@ -222,21 +305,23 @@ class NotifeeAlarmManager {
222
305
  pendingIntent);
223
306
  break;
224
307
  case SET_ALARM_CLOCK:
225
- int mutabilityFlag = PendingIntent.FLAG_UPDATE_CURRENT;
226
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
227
- mutabilityFlag = PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT;
228
- }
229
-
230
- Context context = getApplicationContext();
231
- Intent launchActivityIntent =
232
- context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
233
-
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);
234
317
  PendingIntent pendingLaunchIntent =
235
- PendingIntent.getActivity(
236
- context,
237
- notificationModel.getId().hashCode(),
238
- launchActivityIntent,
239
- mutabilityFlag);
318
+ NotificationPendingIntent.createIntent(
319
+ notificationModel.getHashCode(),
320
+ showIntentPressAction,
321
+ NotificationEvent.TYPE_PRESS,
322
+ new String[] {"notification"},
323
+ notificationModel.toBundle());
324
+
240
325
  AlarmManagerCompat.setAlarmClock(
241
326
  alarmManager, timestampTrigger.getTimestamp(), pendingLaunchIntent, pendingIntent);
242
327
  break;
@@ -254,6 +339,41 @@ class NotifeeAlarmManager {
254
339
  }
255
340
  }
256
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
+
257
377
  ListenableFuture<List<WorkDataEntity>> getScheduledNotifications() {
258
378
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
259
379
  return workDataRepository.getAllWithAlarmManager(true);
@@ -283,10 +403,18 @@ class NotifeeAlarmManager {
283
403
  alarmManagerListeningExecutor);
284
404
  }
285
405
 
286
- /* On reboot, reschedule trigger notifications created via alarm manager */
287
- void rescheduleNotification(WorkDataEntity workDataEntity) {
406
+ /**
407
+ * On reboot, reschedule one trigger notification created via alarm manager.
408
+ *
409
+ * <p>Returns a future that completes when Room has persisted the updated next-fire anchor. The
410
+ * caller MUST await this future before calling {@code pendingResult.finish()} — otherwise Android
411
+ * may kill the boot receiver's process before Room has drained, and the next reboot will
412
+ * reschedule from the stale anchor. This bug is NOT in upstream invertase/notifee#549 and was
413
+ * surfaced only by the pre-fix-549-audit.md read-only caller audit (Caller #8).
414
+ */
415
+ ListenableFuture<Void> rescheduleNotification(WorkDataEntity workDataEntity) {
288
416
  if (workDataEntity.getNotification() == null || workDataEntity.getTrigger() == null) {
289
- return;
417
+ return Futures.immediateFuture(null);
290
418
  }
291
419
 
292
420
  byte[] notificationBytes = workDataEntity.getNotification();
@@ -302,46 +430,210 @@ class NotifeeAlarmManager {
302
430
  case 0:
303
431
  TimestampTriggerModel trigger = TimestampTriggerModel.fromBundle(triggerBundle);
304
432
  if (!trigger.getWithAlarmManager()) {
305
- return;
433
+ return Futures.immediateFuture(null);
434
+ }
435
+
436
+ ListenableFuture<Void> staleResult =
437
+ handleStaleNonRepeatingTrigger(notificationModel, trigger);
438
+ if (staleResult != null) {
439
+ return staleResult;
306
440
  }
307
441
 
308
442
  scheduleTimestampTriggerNotification(notificationModel, trigger);
309
- // Persist updated timestamp so next reboot starts from the correct anchor
310
- WorkDataRepository.getInstance(getApplicationContext())
443
+ // Persist updated timestamp so next reboot starts from the correct anchor.
444
+ return WorkDataRepository.getInstance(getApplicationContext())
311
445
  .update(
312
446
  new WorkDataEntity(
313
447
  workDataEntity.getId(),
314
448
  workDataEntity.getNotification(),
315
449
  ObjectUtils.bundleToBytes(trigger.toBundle()),
316
450
  workDataEntity.getWithAlarmManager()));
317
- break;
318
451
  case 1:
319
452
  // TODO: support interval triggers with alarm manager
320
- break;
453
+ return Futures.immediateFuture(null);
454
+ default:
455
+ return Futures.immediateFuture(null);
321
456
  }
322
457
  }
323
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
+
324
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
+
325
586
  Logger.d(TAG, "Reschedule Notifications on reboot");
326
587
  Futures.addCallback(
327
588
  getScheduledNotifications(),
328
589
  new FutureCallback<List<WorkDataEntity>>() {
329
590
  @Override
330
591
  public void onSuccess(List<WorkDataEntity> workDataEntities) {
331
- try {
332
- for (WorkDataEntity workDataEntity : workDataEntities) {
333
- rescheduleNotification(workDataEntity);
334
- }
335
- } finally {
592
+ Logger.d(
593
+ TAG,
594
+ "Reschedule starting for "
595
+ + (workDataEntities != null ? workDataEntities.size() : 0)
596
+ + " recurring alarms");
597
+
598
+ if (workDataEntities == null || workDataEntities.isEmpty()) {
599
+ releaseLock.run();
336
600
  if (pendingResult != null) {
337
601
  pendingResult.finish();
338
602
  }
603
+ return;
339
604
  }
605
+
606
+ List<ListenableFuture<Void>> updateFutures = new ArrayList<>(workDataEntities.size());
607
+ for (WorkDataEntity workDataEntity : workDataEntities) {
608
+ try {
609
+ updateFutures.add(rescheduleNotification(workDataEntity));
610
+ } catch (Throwable t) {
611
+ // A single bad entity must not prevent the rest of the batch
612
+ // from being rescheduled — log and continue.
613
+ Logger.w(
614
+ TAG,
615
+ "Failed to reschedule entity "
616
+ + workDataEntity.getId()
617
+ + ": "
618
+ + t.getMessage());
619
+ }
620
+ }
621
+
622
+ // Awaits all per-entity update futures before finishing the boot
623
+ // receiver, bounded by the 8s ANR safety timeout. Any not-yet-
624
+ // persisted next-fire anchors left behind on timeout will catch up
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.
629
+ ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
630
+ finishReceiverWhenDone(combined, pendingResult, releaseLock, "rescheduleNotifications");
340
631
  }
341
632
 
342
633
  @Override
343
- public void onFailure(Throwable t) {
634
+ public void onFailure(@NonNull Throwable t) {
344
635
  Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
636
+ releaseLock.run();
345
637
  if (pendingResult != null) {
346
638
  pendingResult.finish();
347
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
  }