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
@@ -0,0 +1,569 @@
1
+ package app.notifee.core;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * ---
13
+ *
14
+ * NOT RUN IN CI.
15
+ *
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
+ *
66
+ * cd apps/smoke/android
67
+ * ./gradlew :react-native-notify-kit:connectedDebugAndroidTest \
68
+ * -Pandroid.testInstrumentationRunnerArguments.class=app.notifee.core.RebootRecoveryTest
69
+ */
70
+
71
+ import static org.junit.Assert.assertEquals;
72
+ import static org.junit.Assert.assertNotNull;
73
+ import static org.junit.Assert.assertTrue;
74
+ import static org.junit.Assert.fail;
75
+
76
+ import android.app.PendingIntent;
77
+ import android.content.Context;
78
+ import android.content.Intent;
79
+ import android.os.Bundle;
80
+ import androidx.core.app.NotificationChannelCompat;
81
+ import androidx.core.app.NotificationManagerCompat;
82
+ import androidx.test.ext.junit.runners.AndroidJUnit4;
83
+ import androidx.test.platform.app.InstrumentationRegistry;
84
+ import app.notifee.core.database.WorkDataEntity;
85
+ import app.notifee.core.database.WorkDataRepository;
86
+ import app.notifee.core.utility.ObjectUtils;
87
+ import java.util.List;
88
+ import java.util.concurrent.TimeUnit;
89
+ import org.junit.After;
90
+ import org.junit.Before;
91
+ import org.junit.Test;
92
+ import org.junit.runner.RunWith;
93
+
94
+ /**
95
+ * ⚠️ WARNING — DESTRUCTIVE INSTRUMENTED TEST ⚠️
96
+ *
97
+ * <p>This test calls {@code WorkDataRepository.getInstance(context)}, which returns the production
98
+ * singleton backed by the on-disk {@code notifee_core_database}. The {@code @Before} and
99
+ * {@code @After} hooks call {@code deleteAll()} on that database.
100
+ *
101
+ * <p>If you run this test on a device that has REAL scheduled notifee notifications (your personal
102
+ * phone, a shared QA device, a customer's device), THOSE NOTIFICATIONS WILL BE SILENTLY DELETED.
103
+ * There is no undo.
104
+ *
105
+ * <p>Run this test only on:
106
+ *
107
+ * <ul>
108
+ * <li>Throwaway emulators
109
+ * <li>Dedicated test devices
110
+ * <li>Devices where you have explicitly verified that no production notifee state exists
111
+ * </ul>
112
+ *
113
+ * <p>A structural fix (migrate to an in-memory Room database for tests) is the correct long-term
114
+ * solution but is out of scope for the #549 fix PR.
115
+ */
116
+ @RunWith(AndroidJUnit4.class)
117
+ public class RebootRecoveryTest {
118
+
119
+ private static final int SEED_COUNT = 5;
120
+ private static final int DAILY_SEED_COUNT = 2;
121
+ private static final long HOUR_IN_MS = 60L * 60 * 1000;
122
+ private static final long DAY_IN_MS = 24 * HOUR_IN_MS;
123
+
124
+ /** 5 hours before "now" — well in the past so setNextTimestamp must advance. */
125
+ private static final long OLD_ANCHOR_OFFSET_MS = 5 * HOUR_IN_MS;
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
+
146
+ private static final long POLL_DEADLINE_MS = 15_000;
147
+ private static final long POLL_INTERVAL_MS = 100;
148
+
149
+ private Context context;
150
+ private WorkDataRepository repo;
151
+
152
+ @Before
153
+ public void setUp() throws Exception {
154
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
155
+ // Ensure ContextHolder is populated — production code paths inside
156
+ // NotifeeAlarmManager call ContextHolder.getApplicationContext() and
157
+ // WorkDataRepository.getInstance(getApplicationContext()).
158
+ ContextHolder.setApplicationContext(context.getApplicationContext());
159
+ repo = WorkDataRepository.getInstance(context);
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);
170
+ }
171
+
172
+ @After
173
+ public void tearDown() throws Exception {
174
+ // Cancel every real alarm the test scheduled so the device is left clean.
175
+ for (int i = 0; i < SEED_COUNT; i++) {
176
+ NotifeeAlarmManager.cancelNotification(testId(i));
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());
189
+ repo.deleteAll().get(5, TimeUnit.SECONDS);
190
+ }
191
+
192
+ @Test
193
+ public void rescheduleNotifications_advancesEveryAnchorBeforeCompleting() throws Exception {
194
+ long now = System.currentTimeMillis();
195
+ long oldAnchor = now - OLD_ANCHOR_OFFSET_MS;
196
+ long minExpectedAnchor = now; // every anchor must move strictly into the future
197
+
198
+ // Seed SEED_COUNT entities, each a recurring hourly alarm with the old anchor.
199
+ for (int i = 0; i < SEED_COUNT; i++) {
200
+ repo.insert(buildEntity(testId(i), oldAnchor)).get(5, TimeUnit.SECONDS);
201
+ }
202
+ assertEquals(SEED_COUNT, repo.getAll().get(5, TimeUnit.SECONDS).size());
203
+
204
+ // Invoke the real reboot-recovery entry point. PendingResult is null because
205
+ // this test is not driven by a BroadcastReceiver goAsync() scope; the fix
206
+ // guards every finish() call with a null-check, so passing null exercises
207
+ // the same future chain without actually broadcasting anything.
208
+ new NotifeeAlarmManager().rescheduleNotifications(null);
209
+
210
+ // Poll for completion. The reschedule fires several async stages
211
+ // (getScheduledNotifications → per-entity update → allAsList → withTimeout),
212
+ // so we cannot directly latch on "done". Instead we observe the side
213
+ // effect: every row's timestamp must have been moved into the future.
214
+ List<WorkDataEntity> rows = awaitAllAnchorsAdvanced(minExpectedAnchor);
215
+
216
+ assertEquals("no rows lost during reboot recovery", SEED_COUNT, rows.size());
217
+ for (WorkDataEntity row : rows) {
218
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(row.getTrigger());
219
+ long newAnchor = ObjectUtils.getLong(triggerBundle.get("timestamp"));
220
+ assertTrue(
221
+ "row "
222
+ + row.getId()
223
+ + " must have an advanced anchor: was="
224
+ + oldAnchor
225
+ + " now="
226
+ + newAnchor,
227
+ newAnchor >= oldAnchor + HOUR_IN_MS);
228
+ assertTrue(
229
+ "row " + row.getId() + " anchor must be in the future: " + newAnchor,
230
+ newAnchor >= minExpectedAnchor);
231
+ }
232
+ }
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
+
408
+ /**
409
+ * Polls {@link WorkDataRepository#getAll()} until every row has a timestamp at or after {@code
410
+ * minExpectedAnchor}, or fails on timeout.
411
+ */
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 {
418
+ long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
419
+ while (System.currentTimeMillis() < deadline) {
420
+ List<WorkDataEntity> rows = repo.getAll().get(5, TimeUnit.SECONDS);
421
+ if (rows.size() == expectedCount && allAdvanced(rows, minExpectedAnchor)) {
422
+ return rows;
423
+ }
424
+ Thread.sleep(POLL_INTERVAL_MS);
425
+ }
426
+ fail(
427
+ "reboot recovery did not advance every anchor within "
428
+ + POLL_DEADLINE_MS
429
+ + "ms — the fix in commit 71fa20e may be broken");
430
+ return null; // unreachable
431
+ }
432
+
433
+ private static boolean allAdvanced(List<WorkDataEntity> rows, long minExpectedAnchor) {
434
+ for (WorkDataEntity row : rows) {
435
+ Bundle trigger = ObjectUtils.bytesToBundle(row.getTrigger());
436
+ long ts = ObjectUtils.getLong(trigger.get("timestamp"));
437
+ if (ts < minExpectedAnchor) {
438
+ return false;
439
+ }
440
+ }
441
+ return true;
442
+ }
443
+
444
+ /** Build a recurring-hourly trigger entity with the given id and initial anchor. */
445
+ private static WorkDataEntity buildEntity(String id, long anchorMs) {
446
+ Bundle notificationBundle = new Bundle();
447
+ notificationBundle.putString("id", id);
448
+ notificationBundle.putString("title", "RebootRecoveryTest " + id);
449
+
450
+ Bundle triggerBundle = new Bundle();
451
+ triggerBundle.putInt("type", 0); // TIMESTAMP
452
+ triggerBundle.putLong("timestamp", anchorMs);
453
+ triggerBundle.putInt("repeatFrequency", 0); // HOURLY
454
+ Bundle alarmManagerBundle = new Bundle();
455
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
456
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
457
+
458
+ return new WorkDataEntity(
459
+ id,
460
+ ObjectUtils.bundleToBytes(notificationBundle),
461
+ ObjectUtils.bundleToBytes(triggerBundle),
462
+ true /* withAlarmManager */);
463
+ }
464
+
465
+ private static String testId(int i) {
466
+ return "reboot-recovery-test-" + i;
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
+ }
569
+ }
@@ -0,0 +1,56 @@
1
+ package app.notifee.core.database;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * ---
13
+ *
14
+ * Test-only WorkDataRepository subclass that captures the wall-clock nanoTime
15
+ * at which deleteById(id)'s underlying DAO call completes. Lives in the
16
+ * database package so it can call the @VisibleForTesting package-private
17
+ * WorkDataRepository(WorkDataDao, ListeningExecutorService) constructor.
18
+ * Consumers (e.g. DoScheduledWorkOrderingTest) instantiate via the public
19
+ * constructor below and inject this instance into the production singleton
20
+ * via reflection.
21
+ */
22
+
23
+ import android.content.Context;
24
+ import com.google.common.util.concurrent.ListenableFuture;
25
+ import com.google.common.util.concurrent.ListeningExecutorService;
26
+ import com.google.common.util.concurrent.MoreExecutors;
27
+ import java.util.concurrent.atomic.AtomicLong;
28
+
29
+ public class TimingWorkDataRepository extends WorkDataRepository {
30
+
31
+ /** nanoTime when the FIRST deleteById future completed. 0 if never called. */
32
+ public final AtomicLong firstDeleteCompletionNanos = new AtomicLong();
33
+
34
+ public TimingWorkDataRepository(WorkDataDao dao, ListeningExecutorService executor) {
35
+ super(dao, executor);
36
+ }
37
+
38
+ /**
39
+ * Factory that wraps the production {@link NotifeeCoreDatabase} DAO and executor, so that reads
40
+ * and writes share the same underlying Room tables as the production singleton.
41
+ */
42
+ public static TimingWorkDataRepository createForProductionDb(Context context) {
43
+ return new TimingWorkDataRepository(
44
+ NotifeeCoreDatabase.getDatabase(context).workDao(),
45
+ NotifeeCoreDatabase.databaseWriteListeningExecutor);
46
+ }
47
+
48
+ @Override
49
+ public ListenableFuture<Void> deleteById(String id) {
50
+ ListenableFuture<Void> f = super.deleteById(id);
51
+ f.addListener(
52
+ () -> firstDeleteCompletionNanos.compareAndSet(0, System.nanoTime()),
53
+ MoreExecutors.directExecutor());
54
+ return f;
55
+ }
56
+ }