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.
@@ -13,28 +13,72 @@ package app.notifee.core;
13
13
  *
14
14
  * NOT RUN IN CI.
15
15
  *
16
- * Instrumented regression test for caller #8 (reboot recovery anchor
17
- * persistence) from pre-fix-549-audit.md. Seeds five recurring trigger rows
18
- * with timestamps in the past, invokes NotifeeAlarmManager.rescheduleNotifications
19
- * via its public entry point, and asserts that every row has an advanced
20
- * timestamp once the reschedule completes — proving that the per-entity
21
- * WorkDataRepository.update(...) futures are awaited before the
22
- * BroadcastReceiver finish path, which is the #549 fix commit
23
- * 71fa20e ("fix(android): persist reboot recovery anchor updates before
24
- * finishing receiver").
25
- *
26
- * Run manually:
16
+ * Instrumented regression suite for the reboot-recovery path in
17
+ * NotifeeAlarmManager.rescheduleNotifications, covering three upstream
18
+ * issues across five test cases:
19
+ *
20
+ * 1. rescheduleNotifications_advancesEveryAnchorBeforeCompleting
21
+ * (upstream invertase/notifee#549 reboot-recovery anchor persistence
22
+ * from caller #8 of pre-fix-549-audit.md). Seeds five HOURLY recurring
23
+ * rows with past anchors and asserts every row has an advanced
24
+ * timestamp once the reschedule completes, proving per-entity
25
+ * WorkDataRepository update futures are awaited before the
26
+ * BroadcastReceiver finish path. Regression guard for commit 71fa20e.
27
+ *
28
+ * 2. rescheduleNotifications_dailyTriggers_advancesStaleAnchorsToFuture
29
+ * (upstream invertase/notifee#839 — DAILY trigger fails to re-fire
30
+ * from day 2 onwards on Android). Seeds stale DAILY anchors, asserts
31
+ * they are advanced to the future AND that an AlarmManager
32
+ * PendingIntent is registered for each row (proving
33
+ * scheduleTimestampTriggerNotification → setNextTimestamp →
34
+ * alarmManager.set* ran in order).
35
+ *
36
+ * 3. rescheduleNotifications_staleNonRepeating_withinGracePeriod_rowIsDeleted
37
+ * (upstream invertase/notifee#734 — zombie stale triggers on OEM
38
+ * reboot-suppressed devices, within 24h grace period). Seeds a stale
39
+ * non-repeating row 1h in the past; asserts the row is deleted after
40
+ * the reschedule pass, proving the fire-once-then-delete primary
41
+ * branch of handleStaleNonRepeatingTrigger ran.
42
+ *
43
+ * 4. rescheduleNotifications_staleNonRepeating_beyondGracePeriod_rowIsDeleted
44
+ * (upstream invertase/notifee#734, beyond 24h grace period). Seeds a
45
+ * stale non-repeating row 48h in the past; asserts the row is deleted
46
+ * without firing a notification (delete-silent branch).
47
+ *
48
+ * 5. rescheduleNotifications_staleNonRepeating_displayFails_rowStillDeleted
49
+ * (upstream invertase/notifee#734 resilience path, discovered via the
50
+ * Step 6 smoke dry-run). Seeds a deliberately malformed non-repeating
51
+ * row (no `android` sub-bundle) so NotificationManager.displayNotification
52
+ * throws NullPointerException inside NotificationAndroidModel.getChannelId.
53
+ * Asserts the row is STILL deleted because Futures.catchingAsync in
54
+ * handleStaleNonRepeatingTrigger routes the failure through the
55
+ * resilient branch that still runs deleteById.
56
+ *
57
+ * Run manually via the smoke harness (recommended — wraps gradle + copies
58
+ * reports + fails loud if no XML reports are found):
59
+ *
60
+ * scripts/verify-step7-fixes.sh
61
+ *
62
+ * Or directly with Gradle. Note that `--tests` is NOT a valid command-line
63
+ * option on the AGP DeviceProviderInstrumentTestTask — use the
64
+ * instrumentation runner argument property instead:
65
+ *
27
66
  * cd apps/smoke/android
28
67
  * ./gradlew :react-native-notify-kit:connectedDebugAndroidTest \
29
- * --tests app.notifee.core.RebootRecoveryTest
68
+ * -Pandroid.testInstrumentationRunnerArguments.class=app.notifee.core.RebootRecoveryTest
30
69
  */
31
70
 
32
71
  import static org.junit.Assert.assertEquals;
72
+ import static org.junit.Assert.assertNotNull;
33
73
  import static org.junit.Assert.assertTrue;
34
74
  import static org.junit.Assert.fail;
35
75
 
76
+ import android.app.PendingIntent;
36
77
  import android.content.Context;
78
+ import android.content.Intent;
37
79
  import android.os.Bundle;
80
+ import androidx.core.app.NotificationChannelCompat;
81
+ import androidx.core.app.NotificationManagerCompat;
38
82
  import androidx.test.ext.junit.runners.AndroidJUnit4;
39
83
  import androidx.test.platform.app.InstrumentationRegistry;
40
84
  import app.notifee.core.database.WorkDataEntity;
@@ -73,11 +117,32 @@ import org.junit.runner.RunWith;
73
117
  public class RebootRecoveryTest {
74
118
 
75
119
  private static final int SEED_COUNT = 5;
120
+ private static final int DAILY_SEED_COUNT = 2;
76
121
  private static final long HOUR_IN_MS = 60L * 60 * 1000;
122
+ private static final long DAY_IN_MS = 24 * HOUR_IN_MS;
77
123
 
78
124
  /** 5 hours before "now" — well in the past so setNextTimestamp must advance. */
79
125
  private static final long OLD_ANCHOR_OFFSET_MS = 5 * HOUR_IN_MS;
80
126
 
127
+ /** 2 days before "now" — stale DAILY anchor that must be advanced by reboot recovery. */
128
+ private static final long OLD_DAILY_ANCHOR_OFFSET_MS = 2 * DAY_IN_MS;
129
+
130
+ /** 1 hour before "now" — well inside the 24h grace period for stale non-repeating triggers. */
131
+ private static final long STALE_WITHIN_GRACE_OFFSET_MS = HOUR_IN_MS;
132
+
133
+ /** 48 hours before "now" — well beyond the 24h grace period. */
134
+ private static final long STALE_BEYOND_GRACE_OFFSET_MS = 48 * HOUR_IN_MS;
135
+
136
+ /**
137
+ * Channel id used by the within-grace stale-trigger test fixture. Must match a real Android
138
+ * notification channel created in {@link #setUp()} so {@code
139
+ * NotificationManager.displayNotification} can build a valid notification from the seeded Room
140
+ * row. Without a matching channel on Android 8+, the display path falls into the resilient
141
+ * catching branch (which is the subject of its own dedicated test), not the primary
142
+ * fire-once-then-delete path.
143
+ */
144
+ private static final String STALE_TEST_CHANNEL_ID = "reboot-recovery-stale-test-channel";
145
+
81
146
  private static final long POLL_DEADLINE_MS = 15_000;
82
147
  private static final long POLL_INTERVAL_MS = 100;
83
148
 
@@ -93,6 +158,15 @@ public class RebootRecoveryTest {
93
158
  ContextHolder.setApplicationContext(context.getApplicationContext());
94
159
  repo = WorkDataRepository.getInstance(context);
95
160
  repo.deleteAll().get(5, TimeUnit.SECONDS);
161
+
162
+ // Register a real notification channel the stale-trigger within-grace test can target.
163
+ // createNotificationChannel is idempotent — repeat calls across tests are a no-op.
164
+ NotificationChannelCompat channel =
165
+ new NotificationChannelCompat.Builder(
166
+ STALE_TEST_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
167
+ .setName("Reboot Recovery Test Channel")
168
+ .build();
169
+ NotificationManagerCompat.from(context).createNotificationChannel(channel);
96
170
  }
97
171
 
98
172
  @After
@@ -101,6 +175,17 @@ public class RebootRecoveryTest {
101
175
  for (int i = 0; i < SEED_COUNT; i++) {
102
176
  NotifeeAlarmManager.cancelNotification(testId(i));
103
177
  }
178
+ for (int i = 0; i < DAILY_SEED_COUNT; i++) {
179
+ NotifeeAlarmManager.cancelNotification(dailyTestId(i));
180
+ }
181
+ NotifeeAlarmManager.cancelNotification(staleWithinGraceTestId());
182
+ NotifeeAlarmManager.cancelNotification(staleBeyondGraceTestId());
183
+ NotifeeAlarmManager.cancelNotification(staleDisplayFailsTestId());
184
+ // Dismiss any notifications the within-grace tests may have posted so the device
185
+ // notification shade is left clean — the Room row is the test's contract, but the
186
+ // visible notification is a side effect we should not leave behind.
187
+ NotificationManagerCompat.from(context).cancel(staleWithinGraceTestId().hashCode());
188
+ NotificationManagerCompat.from(context).cancel(staleDisplayFailsTestId().hashCode());
104
189
  repo.deleteAll().get(5, TimeUnit.SECONDS);
105
190
  }
106
191
 
@@ -146,15 +231,194 @@ public class RebootRecoveryTest {
146
231
  }
147
232
  }
148
233
 
234
+ /**
235
+ * Regression test for upstream invertase/notifee#839 — DAILY trigger fails to re-fire from day 2
236
+ * onwards on Android. Seeds {@value DAILY_SEED_COUNT} stale DAILY anchors, invokes reboot
237
+ * recovery, and asserts that every row's timestamp has been advanced into the future AND that an
238
+ * AlarmManager PendingIntent has been registered for each row (proving the fix path
239
+ * scheduleTimestampTriggerNotification → setNextTimestamp → alarmManager.set* ran in order).
240
+ */
241
+ @Test
242
+ public void rescheduleNotifications_dailyTriggers_advancesStaleAnchorsToFuture()
243
+ throws Exception {
244
+ long now = System.currentTimeMillis();
245
+ long oldAnchor = now - OLD_DAILY_ANCHOR_OFFSET_MS;
246
+ long minExpectedAnchor = now;
247
+
248
+ for (int i = 0; i < DAILY_SEED_COUNT; i++) {
249
+ repo.insert(buildDailyEntity(dailyTestId(i), oldAnchor)).get(5, TimeUnit.SECONDS);
250
+ }
251
+ assertEquals(DAILY_SEED_COUNT, repo.getAll().get(5, TimeUnit.SECONDS).size());
252
+
253
+ new NotifeeAlarmManager().rescheduleNotifications(null);
254
+
255
+ List<WorkDataEntity> rows =
256
+ awaitAllAnchorsAdvancedWithExpectedCount(minExpectedAnchor, DAILY_SEED_COUNT);
257
+
258
+ assertEquals("no DAILY rows lost during reboot recovery", DAILY_SEED_COUNT, rows.size());
259
+ for (WorkDataEntity row : rows) {
260
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(row.getTrigger());
261
+ long newAnchor = ObjectUtils.getLong(triggerBundle.get("timestamp"));
262
+ assertTrue(
263
+ "DAILY row "
264
+ + row.getId()
265
+ + " must be advanced to the future: was="
266
+ + oldAnchor
267
+ + " now="
268
+ + newAnchor,
269
+ newAnchor >= minExpectedAnchor);
270
+ assertTrue(
271
+ "DAILY row " + row.getId() + " must not be advanced more than 25h ahead: " + newAnchor,
272
+ newAnchor < now + 25L * HOUR_IN_MS);
273
+ }
274
+
275
+ // Every DAILY row must have a live AlarmManager PendingIntent registered. FLAG_NO_CREATE
276
+ // returns null if no matching PendingIntent exists — which would mean
277
+ // scheduleTimestampTriggerNotification never ran or setNextTimestamp did not reach the
278
+ // alarmManager.set* call.
279
+ for (int i = 0; i < DAILY_SEED_COUNT; i++) {
280
+ Intent intent = new Intent(context, NotificationAlarmReceiver.class);
281
+ intent.putExtra("notificationId", dailyTestId(i));
282
+ PendingIntent pi =
283
+ PendingIntent.getBroadcast(
284
+ context,
285
+ dailyTestId(i).hashCode(),
286
+ intent,
287
+ PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_MUTABLE);
288
+ assertNotNull(
289
+ "AlarmManager PendingIntent must be registered for DAILY row " + dailyTestId(i), pi);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Regression test for upstream invertase/notifee#734 within the 24h grace period. Seeds a stale
295
+ * non-repeating TIMESTAMP trigger whose fire time is 1h in the past, invokes reboot recovery, and
296
+ * asserts that the Room row is deleted (proving the fire-once-then-delete path ran). The
297
+ * late-fire itself is a side effect observable only via NotificationManagerCompat; this test
298
+ * asserts the Room-state contract, which is the guarantee most relevant to preventing zombie
299
+ * re-fire loops on every subsequent reboot.
300
+ */
301
+ @Test
302
+ public void rescheduleNotifications_staleNonRepeating_withinGracePeriod_rowIsDeleted()
303
+ throws Exception {
304
+ long now = System.currentTimeMillis();
305
+ long staleAnchor = now - STALE_WITHIN_GRACE_OFFSET_MS;
306
+ String id = staleWithinGraceTestId();
307
+
308
+ repo.insert(buildNonRepeatingEntity(id, staleAnchor)).get(5, TimeUnit.SECONDS);
309
+ assertEquals(1, repo.getAll().get(5, TimeUnit.SECONDS).size());
310
+
311
+ new NotifeeAlarmManager().rescheduleNotifications(null);
312
+
313
+ // Poll until the row is gone. The reschedule path fires a transformAsync:
314
+ // NotificationManager.displayNotification(...) → WorkDataRepository.deleteById(id)
315
+ // so we observe the deletion as the terminal side effect.
316
+ awaitRowDeleted(id);
317
+ assertTrue(
318
+ "stale non-repeating row must be removed from Room after reboot recovery",
319
+ repo.getWorkDataById(id).get(5, TimeUnit.SECONDS) == null);
320
+ }
321
+
322
+ /**
323
+ * Regression test for upstream invertase/notifee#734 resilience path (discovered via the Step 6
324
+ * smoke dry-run). Seeds a deliberately malformed non-repeating trigger — missing the {@code
325
+ * android} sub-bundle entirely, which causes {@code NotificationAndroidModel.getChannelId} to
326
+ * throw {@link NullPointerException} when {@code NotificationManager.displayNotification} tries
327
+ * to build a notification from it. The resilience guarantee is that {@code
328
+ * handleStaleNonRepeatingTrigger} must STILL delete the Room row even when the late-fire fails,
329
+ * otherwise the zombie re-fire loop (which Bundle A of #734 is supposed to break) simply moves
330
+ * from "row with valid bundle" to "row with corrupt bundle" and keeps re-appearing on every
331
+ * reboot.
332
+ *
333
+ * <p>In production this scenario is reachable when a consumer upgrades from an older library
334
+ * version whose serialized Room state has a different shape than the current model expects, when
335
+ * a notification channel has been deleted between scheduling and recovery, when a
336
+ * SecurityException is thrown deep inside the display chain on a restrictive OEM device, or for
337
+ * any other reason a previously-validated notification payload can no longer be displayed.
338
+ */
339
+ @Test
340
+ public void rescheduleNotifications_staleNonRepeating_displayFails_rowStillDeleted()
341
+ throws Exception {
342
+ long now = System.currentTimeMillis();
343
+ long staleAnchor = now - STALE_WITHIN_GRACE_OFFSET_MS;
344
+ String id = staleDisplayFailsTestId();
345
+
346
+ repo.insert(buildMalformedNonRepeatingEntity(id, staleAnchor)).get(5, TimeUnit.SECONDS);
347
+ assertEquals(1, repo.getAll().get(5, TimeUnit.SECONDS).size());
348
+
349
+ new NotifeeAlarmManager().rescheduleNotifications(null);
350
+
351
+ // The resilient chain is:
352
+ // displayNotification
353
+ // → CATCHING Exception (narrowed in Step 7 to let Error propagate)
354
+ // → Futures.immediateFuture(null)
355
+ // → deleteById
356
+ // So even though the first step explodes on NullPointerException, the deleteById continuation
357
+ // still runs. We observe the same terminal state as the happy path: the Room row is gone.
358
+ awaitRowDeleted(id);
359
+ assertTrue(
360
+ "malformed stale row must still be deleted after late-fire failure — the #734 resilience"
361
+ + " path in handleStaleNonRepeatingTrigger may be broken",
362
+ repo.getWorkDataById(id).get(5, TimeUnit.SECONDS) == null);
363
+ }
364
+
365
+ /**
366
+ * Regression test for upstream invertase/notifee#734 beyond the 24h grace period. Seeds a stale
367
+ * non-repeating TIMESTAMP trigger whose fire time is 48h in the past, invokes reboot recovery,
368
+ * and asserts that the Room row is deleted WITHOUT the notification being fired (delete-silent
369
+ * path). This path avoids showing stale content to users whose devices were off or whose app was
370
+ * killed long enough that the original notification is no longer relevant.
371
+ */
372
+ @Test
373
+ public void rescheduleNotifications_staleNonRepeating_beyondGracePeriod_rowIsDeleted()
374
+ throws Exception {
375
+ long now = System.currentTimeMillis();
376
+ long staleAnchor = now - STALE_BEYOND_GRACE_OFFSET_MS;
377
+ String id = staleBeyondGraceTestId();
378
+
379
+ repo.insert(buildNonRepeatingEntity(id, staleAnchor)).get(5, TimeUnit.SECONDS);
380
+ assertEquals(1, repo.getAll().get(5, TimeUnit.SECONDS).size());
381
+
382
+ new NotifeeAlarmManager().rescheduleNotifications(null);
383
+
384
+ awaitRowDeleted(id);
385
+ assertTrue(
386
+ "stale non-repeating row must be removed from Room after reboot recovery",
387
+ repo.getWorkDataById(id).get(5, TimeUnit.SECONDS) == null);
388
+ }
389
+
390
+ /** Polls {@link WorkDataRepository#getWorkDataById(String)} until it returns {@code null}. */
391
+ private void awaitRowDeleted(String id) throws Exception {
392
+ long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
393
+ while (System.currentTimeMillis() < deadline) {
394
+ WorkDataEntity row = repo.getWorkDataById(id).get(5, TimeUnit.SECONDS);
395
+ if (row == null) {
396
+ return;
397
+ }
398
+ Thread.sleep(POLL_INTERVAL_MS);
399
+ }
400
+ fail(
401
+ "stale non-repeating row "
402
+ + id
403
+ + " was not deleted within "
404
+ + POLL_DEADLINE_MS
405
+ + "ms — the #734 fix in rescheduleNotification may be broken");
406
+ }
407
+
149
408
  /**
150
409
  * Polls {@link WorkDataRepository#getAll()} until every row has a timestamp at or after {@code
151
410
  * minExpectedAnchor}, or fails on timeout.
152
411
  */
153
412
  private List<WorkDataEntity> awaitAllAnchorsAdvanced(long minExpectedAnchor) throws Exception {
413
+ return awaitAllAnchorsAdvancedWithExpectedCount(minExpectedAnchor, SEED_COUNT);
414
+ }
415
+
416
+ private List<WorkDataEntity> awaitAllAnchorsAdvancedWithExpectedCount(
417
+ long minExpectedAnchor, int expectedCount) throws Exception {
154
418
  long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
155
419
  while (System.currentTimeMillis() < deadline) {
156
420
  List<WorkDataEntity> rows = repo.getAll().get(5, TimeUnit.SECONDS);
157
- if (rows.size() == SEED_COUNT && allAdvanced(rows, minExpectedAnchor)) {
421
+ if (rows.size() == expectedCount && allAdvanced(rows, minExpectedAnchor)) {
158
422
  return rows;
159
423
  }
160
424
  Thread.sleep(POLL_INTERVAL_MS);
@@ -201,4 +465,105 @@ public class RebootRecoveryTest {
201
465
  private static String testId(int i) {
202
466
  return "reboot-recovery-test-" + i;
203
467
  }
468
+
469
+ /** Build a recurring-daily trigger entity with the given id and initial anchor. */
470
+ private static WorkDataEntity buildDailyEntity(String id, long anchorMs) {
471
+ Bundle notificationBundle = new Bundle();
472
+ notificationBundle.putString("id", id);
473
+ notificationBundle.putString("title", "RebootRecoveryTest daily " + id);
474
+
475
+ Bundle triggerBundle = new Bundle();
476
+ triggerBundle.putInt("type", 0); // TIMESTAMP
477
+ triggerBundle.putLong("timestamp", anchorMs);
478
+ triggerBundle.putInt("repeatFrequency", 1); // DAILY
479
+ Bundle alarmManagerBundle = new Bundle();
480
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
481
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
482
+
483
+ return new WorkDataEntity(
484
+ id,
485
+ ObjectUtils.bundleToBytes(notificationBundle),
486
+ ObjectUtils.bundleToBytes(triggerBundle),
487
+ true /* withAlarmManager */);
488
+ }
489
+
490
+ private static String dailyTestId(int i) {
491
+ return "reboot-recovery-daily-test-" + i;
492
+ }
493
+
494
+ /**
495
+ * Build a one-shot (non-repeating) TIMESTAMP trigger entity with a well-formed {@code android}
496
+ * sub-bundle pointing at {@link #STALE_TEST_CHANNEL_ID}. Used by the within-grace and
497
+ * beyond-grace tests that exercise the primary decision-tree branches of {@code
498
+ * handleStaleNonRepeatingTrigger}.
499
+ */
500
+ private static WorkDataEntity buildNonRepeatingEntity(String id, long anchorMs) {
501
+ Bundle notificationBundle = new Bundle();
502
+ notificationBundle.putString("id", id);
503
+ notificationBundle.putString("title", "RebootRecoveryTest stale " + id);
504
+
505
+ // Without this, NotificationAndroidModel.getChannelId NPEs inside the display path
506
+ // (mNotificationAndroidBundle is null). Production notifications never reach this
507
+ // code path without a channelId because the TypeScript validators require it, but
508
+ // test fixtures that bypass the JS layer must synthesize the shape manually.
509
+ Bundle androidBundle = new Bundle();
510
+ androidBundle.putString("channelId", STALE_TEST_CHANNEL_ID);
511
+ notificationBundle.putBundle("android", androidBundle);
512
+
513
+ Bundle triggerBundle = new Bundle();
514
+ triggerBundle.putInt("type", 0); // TIMESTAMP
515
+ triggerBundle.putLong("timestamp", anchorMs);
516
+ triggerBundle.putInt("repeatFrequency", -1); // non-repeating
517
+ Bundle alarmManagerBundle = new Bundle();
518
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
519
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
520
+
521
+ return new WorkDataEntity(
522
+ id,
523
+ ObjectUtils.bundleToBytes(notificationBundle),
524
+ ObjectUtils.bundleToBytes(triggerBundle),
525
+ true /* withAlarmManager */);
526
+ }
527
+
528
+ /**
529
+ * Build a deliberately malformed non-repeating TIMESTAMP trigger entity that will cause {@code
530
+ * NotificationManager.displayNotification} to throw {@link NullPointerException} inside {@code
531
+ * NotificationAndroidModel.getChannelId}. Used by {@link
532
+ * #rescheduleNotifications_staleNonRepeating_displayFails_rowStillDeleted} to prove the {@code
533
+ * Futures.catchingAsync} resilience branch in {@code handleStaleNonRepeatingTrigger} deletes the
534
+ * Room row even when the late-fire fails. The malformation — omitting the {@code android}
535
+ * sub-bundle — matches the shape a corrupted-upgrade Room row could take in production.
536
+ */
537
+ private static WorkDataEntity buildMalformedNonRepeatingEntity(String id, long anchorMs) {
538
+ Bundle notificationBundle = new Bundle();
539
+ notificationBundle.putString("id", id);
540
+ notificationBundle.putString("title", "RebootRecoveryTest malformed " + id);
541
+ // INTENTIONALLY NO android sub-bundle — this is the failure shape.
542
+
543
+ Bundle triggerBundle = new Bundle();
544
+ triggerBundle.putInt("type", 0); // TIMESTAMP
545
+ triggerBundle.putLong("timestamp", anchorMs);
546
+ triggerBundle.putInt("repeatFrequency", -1); // non-repeating
547
+ Bundle alarmManagerBundle = new Bundle();
548
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
549
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
550
+
551
+ return new WorkDataEntity(
552
+ id,
553
+ ObjectUtils.bundleToBytes(notificationBundle),
554
+ ObjectUtils.bundleToBytes(triggerBundle),
555
+ true /* withAlarmManager */);
556
+ }
557
+
558
+ private static String staleWithinGraceTestId() {
559
+ return "reboot-recovery-stale-within-grace-test";
560
+ }
561
+
562
+ private static String staleBeyondGraceTestId() {
563
+ return "reboot-recovery-stale-beyond-grace-test";
564
+ }
565
+
566
+ private static String staleDisplayFailsTestId() {
567
+ return "reboot-recovery-stale-display-fails-test";
568
+ }
204
569
  }
@@ -5,21 +5,32 @@ import static android.app.AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_ST
5
5
  import android.content.BroadcastReceiver;
6
6
  import android.content.Context;
7
7
  import android.content.Intent;
8
- import android.util.Log;
9
8
 
10
9
  public class AlarmPermissionBroadcastReceiver extends BroadcastReceiver {
10
+ private static final String TAG = "AlarmPermissionReceiver";
11
+
11
12
  @Override
12
13
  public void onReceive(Context context, Intent intent) {
14
+ if (!ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED.equals(intent.getAction())) {
15
+ return;
16
+ }
13
17
 
14
- if (intent.getAction().equals(ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)) {
15
- PendingResult pendingResult = goAsync();
16
- Log.i("AlarmPermissionReceiver", "Received alarm permission state changed event");
17
-
18
+ PendingResult pendingResult = goAsync();
19
+ // See RebootBroadcastReceiver for the rationale behind this guard.
20
+ boolean asyncHandoffSucceeded = false;
21
+ try {
22
+ Logger.i(TAG, "Received alarm permission state changed event");
18
23
  if (ContextHolder.getApplicationContext() == null) {
19
24
  ContextHolder.setApplicationContext(context.getApplicationContext());
20
25
  }
21
-
22
26
  new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
27
+ asyncHandoffSucceeded = true;
28
+ } catch (Throwable t) {
29
+ Logger.e(TAG, "Failed to reschedule notifications after permission change", t);
30
+ } finally {
31
+ if (!asyncHandoffSucceeded && pendingResult != null) {
32
+ pendingResult.finish();
33
+ }
23
34
  }
24
35
  }
25
36
  }
@@ -26,9 +26,11 @@ import android.content.pm.ProviderInfo;
26
26
  import android.database.Cursor;
27
27
  import android.net.Uri;
28
28
  import android.os.Trace;
29
+ import android.provider.Settings;
29
30
  import androidx.annotation.CallSuper;
30
31
  import androidx.annotation.NonNull;
31
32
  import androidx.annotation.Nullable;
33
+ import androidx.annotation.VisibleForTesting;
32
34
  import java.util.concurrent.ExecutorService;
33
35
  import java.util.concurrent.Executors;
34
36
 
@@ -64,8 +66,15 @@ public class InitProvider extends ContentProvider {
64
66
  }
65
67
 
66
68
  Context appContext = ContextHolder.getApplicationContext();
67
- if (appContext != null && isWarmupEnabled(appContext)) {
68
- dispatchWarmup(appContext);
69
+ if (appContext != null) {
70
+ if (isWarmupEnabled(appContext)) {
71
+ dispatchWarmup(appContext);
72
+ }
73
+ // Fix for upstream invertase/notifee#734: recover scheduled alarms on every app init
74
+ // when a reboot has occurred since the last run. Runs unconditionally (not gated by
75
+ // the warmup metadata flag) because this is recovery, not optimisation. Always on a
76
+ // background thread to keep app startup clean.
77
+ dispatchBootCheck(appContext);
69
78
  }
70
79
  } finally {
71
80
  Trace.endSection();
@@ -100,6 +109,104 @@ public class InitProvider extends ContentProvider {
100
109
  executor.shutdown();
101
110
  }
102
111
 
112
+ /**
113
+ * Dispatches the BOOT_COUNT cold-start recovery check on a background thread. Mirrors {@link
114
+ * #dispatchWarmup} so that app startup stays free of I/O. Fix for upstream invertase/notifee#734.
115
+ */
116
+ private static void dispatchBootCheck(final Context context) {
117
+ ExecutorService executor =
118
+ Executors.newSingleThreadExecutor(
119
+ r -> {
120
+ Thread t = new Thread(r, "notifee-boot-check");
121
+ t.setDaemon(true);
122
+ t.setPriority(Thread.MIN_PRIORITY);
123
+ return t;
124
+ });
125
+
126
+ executor.submit(() -> runBootCheck(context));
127
+ executor.shutdown();
128
+ }
129
+
130
+ /**
131
+ * Reads {@code Settings.Global.BOOT_COUNT} and compares it against the last-known value stored in
132
+ * {@link Preferences}. If a reboot has occurred since the last app run — or if BOOT_COUNT cannot
133
+ * be read (custom ROMs, emulators, exotic vendors) — invokes {@link
134
+ * NotifeeAlarmManager#rescheduleNotifications(android.content.BroadcastReceiver.PendingResult)}
135
+ * to recover any AlarmManager triggers that may not have been re-armed by {@code
136
+ * RebootBroadcastReceiver} on OEM devices that suppress {@code BOOT_COMPLETED}.
137
+ *
138
+ * <p>Package-private for direct invocation from Robolectric unit tests.
139
+ */
140
+ static void runBootCheck(Context context) {
141
+ try {
142
+ int currentBootCount = readBootCount(context);
143
+ int lastKnownBootCount =
144
+ Preferences.getSharedInstance().getIntValue(Preferences.LAST_KNOWN_BOOT_COUNT_KEY, -1);
145
+
146
+ boolean shouldReschedule = shouldRescheduleAfterBoot(currentBootCount, lastKnownBootCount);
147
+
148
+ if (currentBootCount == -1 && lastKnownBootCount == -1) {
149
+ Logger.i(TAG, "BOOT_COUNT unavailable on first run; running conservative reschedule");
150
+ } else if (currentBootCount == -1) {
151
+ Logger.i(TAG, "BOOT_COUNT unavailable; running conservative reschedule to be safe");
152
+ } else if (lastKnownBootCount == -1) {
153
+ Logger.i(TAG, "First run: recording BOOT_COUNT baseline " + currentBootCount);
154
+ } else if (currentBootCount != lastKnownBootCount) {
155
+ Logger.i(
156
+ TAG,
157
+ "Boot detected since last run ("
158
+ + lastKnownBootCount
159
+ + " -> "
160
+ + currentBootCount
161
+ + "), rescheduling");
162
+ }
163
+
164
+ // Only persist real values. Writing -1 would erase a prior real baseline
165
+ // and cause every subsequent init to run a conservative reschedule.
166
+ if (currentBootCount != -1) {
167
+ Preferences.getSharedInstance()
168
+ .setIntValue(Preferences.LAST_KNOWN_BOOT_COUNT_KEY, currentBootCount);
169
+ }
170
+
171
+ if (shouldReschedule) {
172
+ new NotifeeAlarmManager().rescheduleNotifications(null);
173
+ }
174
+ } catch (Throwable t) {
175
+ Logger.e(TAG, "Cold-start reschedule check failed", t);
176
+ }
177
+ }
178
+
179
+ private static int readBootCount(Context context) {
180
+ try {
181
+ return Settings.Global.getInt(context.getContentResolver(), Settings.Global.BOOT_COUNT, -1);
182
+ } catch (Throwable t) {
183
+ Logger.w(TAG, "Failed to read Settings.Global.BOOT_COUNT", t);
184
+ return -1;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Pure decision function for BOOT_COUNT-based cold-start recovery. Returns {@code true} when the
190
+ * current init should trigger a reschedule pass, {@code false} otherwise.
191
+ *
192
+ * <ul>
193
+ * <li>{@code currentBootCount == -1} → BOOT_COUNT unavailable; reschedule conservatively.
194
+ * <li>{@code lastKnownBootCount == -1} → first run; record baseline without rescheduling.
195
+ * <li>{@code currentBootCount != lastKnownBootCount} → reboot detected; reschedule.
196
+ * <li>Otherwise → same boot as last run; no-op.
197
+ * </ul>
198
+ */
199
+ @VisibleForTesting
200
+ static boolean shouldRescheduleAfterBoot(int currentBootCount, int lastKnownBootCount) {
201
+ if (currentBootCount == -1) {
202
+ return true;
203
+ }
204
+ if (lastKnownBootCount == -1) {
205
+ return false;
206
+ }
207
+ return currentBootCount != lastKnownBootCount;
208
+ }
209
+
103
210
  @Nullable
104
211
  @Override
105
212
  public Cursor query(