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.
- package/README.md +200 -5
- package/android/build.gradle +1 -0
- package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
- package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
- package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
- package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
- package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
- 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 +385 -93
- package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
- package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
- 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/java/app/notifee/core/database/WorkDataRepository.java +38 -34
- 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/database/WorkDataRepositoryFutureContractTest.java +279 -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
|
@@ -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
|
+
}
|