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.
- package/README.md +190 -1
- package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +378 -13
- package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
- package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
- package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +228 -17
- package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
- package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
- package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
- package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
- package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
- package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
- package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/version.ts +1 -1
|
@@ -13,28 +13,72 @@ package app.notifee.core;
|
|
|
13
13
|
*
|
|
14
14
|
* NOT RUN IN CI.
|
|
15
15
|
*
|
|
16
|
-
* Instrumented regression
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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() ==
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
68
|
-
|
|
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(
|