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
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
package app.notifee.core;
|
|
2
2
|
|
|
3
3
|
import static org.junit.Assert.assertEquals;
|
|
4
|
+
import static org.junit.Assert.assertNotNull;
|
|
5
|
+
import static org.junit.Assert.assertNull;
|
|
4
6
|
import static org.junit.Assert.assertTrue;
|
|
5
7
|
import static org.junit.Assert.fail;
|
|
6
8
|
|
|
9
|
+
import android.app.Notification;
|
|
10
|
+
import android.app.NotificationChannel;
|
|
11
|
+
import android.app.NotificationManager;
|
|
7
12
|
import android.app.Service;
|
|
13
|
+
import android.content.Context;
|
|
8
14
|
import android.content.Intent;
|
|
15
|
+
import android.content.pm.ServiceInfo;
|
|
16
|
+
import android.os.Bundle;
|
|
17
|
+
import androidx.core.app.NotificationCompat;
|
|
18
|
+
import app.notifee.core.event.NotificationEvent;
|
|
19
|
+
import java.lang.reflect.Field;
|
|
20
|
+
import java.util.ArrayList;
|
|
21
|
+
import java.util.List;
|
|
22
|
+
import org.greenrobot.eventbus.Subscribe;
|
|
23
|
+
import org.greenrobot.eventbus.ThreadMode;
|
|
9
24
|
import org.junit.After;
|
|
10
25
|
import org.junit.Before;
|
|
11
26
|
import org.junit.Test;
|
|
@@ -26,12 +41,15 @@ public class ForegroundServiceTest {
|
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
@After
|
|
29
|
-
public void tearDown() {
|
|
30
|
-
// Reset
|
|
31
|
-
//
|
|
32
|
-
//
|
|
44
|
+
public void tearDown() throws Exception {
|
|
45
|
+
// Reset public and private static fields to prevent cross-test pollution. The three private
|
|
46
|
+
// statics are cleared via reflection here so that tests which seed them directly (to exercise
|
|
47
|
+
// onTimeout without running the full START path) cannot leak state into neighbouring tests.
|
|
33
48
|
ForegroundService.mCurrentNotificationId = null;
|
|
34
49
|
ForegroundService.mCurrentForegroundServiceType = -1;
|
|
50
|
+
setPrivateStatic("mCurrentNotificationBundle", null);
|
|
51
|
+
setPrivateStatic("mCurrentNotification", null);
|
|
52
|
+
setPrivateStatic("mCurrentHashCode", 0);
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
@@ -179,4 +197,233 @@ public class ForegroundServiceTest {
|
|
|
179
197
|
int result = service.onStartCommand(stopIntent, 0, 2);
|
|
180
198
|
assertEquals(Service.START_STICKY_COMPATIBILITY, result);
|
|
181
199
|
}
|
|
200
|
+
|
|
201
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
202
|
+
// START path and onTimeout event emission (regression guards for 9.1.13)
|
|
203
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
private static final String TEST_CHANNEL_ID = "fgs-test-channel";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* START happy path: a valid intent with a notification payload must drive the service through
|
|
209
|
+
* {@code startForeground()} and leave {@code mCurrentNotificationId} populated so a subsequent
|
|
210
|
+
* STOP/onTimeout can reference it. Runs on SDK 33 to avoid the API 34+ manifest-type check, which
|
|
211
|
+
* would require a test-specific {@code foregroundServiceType} declaration.
|
|
212
|
+
*/
|
|
213
|
+
@Test
|
|
214
|
+
@Config(sdk = 33)
|
|
215
|
+
public void onStartCommand_startIntent_setsCurrentNotificationIdAndCallsStartForeground()
|
|
216
|
+
throws Exception {
|
|
217
|
+
createChannel();
|
|
218
|
+
ServiceController<ForegroundService> controller =
|
|
219
|
+
Robolectric.buildService(ForegroundService.class);
|
|
220
|
+
ForegroundService service = controller.create().get();
|
|
221
|
+
|
|
222
|
+
String id = "fgs-start-happy";
|
|
223
|
+
int result =
|
|
224
|
+
service.onStartCommand(
|
|
225
|
+
buildStartIntent(id, id.hashCode()), /* flags= */ 0, /* startId= */ 1);
|
|
226
|
+
|
|
227
|
+
assertEquals(Service.START_NOT_STICKY, result);
|
|
228
|
+
assertEquals(id, ForegroundService.mCurrentNotificationId);
|
|
229
|
+
// Robolectric's ShadowService records the most recent Notification passed to
|
|
230
|
+
// startForeground(); a non-null result proves the 3-arg startForeground() overload on the
|
|
231
|
+
// API 33 branch of onStartCommand was exercised and Android's contract was satisfied.
|
|
232
|
+
Notification posted = org.robolectric.Shadows.shadowOf(service).getLastForegroundNotification();
|
|
233
|
+
assertNotNull("startForeground() must have been called during the START path", posted);
|
|
234
|
+
// The private static mCurrentHashCode tracks the caller-supplied hash; verifying it via
|
|
235
|
+
// reflection proves the START branch fully ran (not just the early-return path).
|
|
236
|
+
Field hashField = ForegroundService.class.getDeclaredField("mCurrentHashCode");
|
|
237
|
+
hashField.setAccessible(true);
|
|
238
|
+
assertEquals(id.hashCode(), hashField.getInt(null));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Regression guard for the 9.1.13 {@code onTimeout(int)} fix (upstream invertase/notifee#703). On
|
|
243
|
+
* API 34, Android's single-argument {@code onTimeout} fires when a {@code shortService} FGS
|
|
244
|
+
* exceeds its 3-minute budget. The handler must:
|
|
245
|
+
*
|
|
246
|
+
* <ol>
|
|
247
|
+
* <li>emit a {@link NotificationEvent} with type {@link NotificationEvent#TYPE_FG_TIMEOUT},
|
|
248
|
+
* <li>carry the originating notification model so JS can correlate the event,
|
|
249
|
+
* <li>populate {@code startId} and {@code fgsType} extras (the latter is {@code -1} as a
|
|
250
|
+
* sentinel on the single-argument variant), and
|
|
251
|
+
* <li>reset the service's static tracking state.
|
|
252
|
+
* </ol>
|
|
253
|
+
*/
|
|
254
|
+
@Test
|
|
255
|
+
@Config(sdk = 34)
|
|
256
|
+
public void onTimeout_api34_emitsFgTimeoutEventWithStartIdAndSentinelFgsType() throws Exception {
|
|
257
|
+
ServiceController<ForegroundService> controller =
|
|
258
|
+
Robolectric.buildService(ForegroundService.class);
|
|
259
|
+
ForegroundService service = controller.create().get();
|
|
260
|
+
|
|
261
|
+
String id = "fgs-timeout-api34";
|
|
262
|
+
seedActiveForegroundServiceState(id);
|
|
263
|
+
|
|
264
|
+
FgsEventCapture capture = new FgsEventCapture();
|
|
265
|
+
EventBus.register(capture);
|
|
266
|
+
try {
|
|
267
|
+
int startId = 42;
|
|
268
|
+
service.onTimeout(startId);
|
|
269
|
+
|
|
270
|
+
assertEquals(
|
|
271
|
+
"exactly one NotificationEvent should be emitted on timeout", 1, capture.events.size());
|
|
272
|
+
NotificationEvent event = capture.events.get(0);
|
|
273
|
+
assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
|
|
274
|
+
assertNotNull(
|
|
275
|
+
"timeout event must carry the originating notification", event.getNotification());
|
|
276
|
+
assertEquals(id, event.getNotification().getId());
|
|
277
|
+
assertNotNull("timeout event must carry startId/fgsType extras", event.getExtras());
|
|
278
|
+
assertEquals(startId, event.getExtras().getInt("startId"));
|
|
279
|
+
// handleTimeout is called with -1 as the fgsType sentinel from the single-argument overload.
|
|
280
|
+
assertEquals(-1, event.getExtras().getInt("fgsType"));
|
|
281
|
+
} finally {
|
|
282
|
+
EventBus.unregister(capture);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
assertNull(
|
|
286
|
+
"mCurrentNotificationId must be cleared after onTimeout",
|
|
287
|
+
ForegroundService.mCurrentNotificationId);
|
|
288
|
+
assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Regression guard for the API 35+ {@code onTimeout(int, int)} overload, which supersedes the
|
|
293
|
+
* single-argument variant and surfaces the type-specific timeout cause (e.g. {@code
|
|
294
|
+
* FOREGROUND_SERVICE_TYPE_DATA_SYNC}'s new Android 15 cumulative cap). The emitted event must
|
|
295
|
+
* carry the explicit {@code fgsType} value, not the sentinel.
|
|
296
|
+
*/
|
|
297
|
+
@Test
|
|
298
|
+
@Config(sdk = 35)
|
|
299
|
+
public void onTimeout_api35_emitsFgTimeoutEventWithExplicitFgsType() throws Exception {
|
|
300
|
+
ServiceController<ForegroundService> controller =
|
|
301
|
+
Robolectric.buildService(ForegroundService.class);
|
|
302
|
+
ForegroundService service = controller.create().get();
|
|
303
|
+
|
|
304
|
+
String id = "fgs-timeout-api35";
|
|
305
|
+
seedActiveForegroundServiceState(id);
|
|
306
|
+
|
|
307
|
+
FgsEventCapture capture = new FgsEventCapture();
|
|
308
|
+
EventBus.register(capture);
|
|
309
|
+
try {
|
|
310
|
+
int startId = 7;
|
|
311
|
+
int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
|
|
312
|
+
service.onTimeout(startId, fgsType);
|
|
313
|
+
|
|
314
|
+
assertEquals(1, capture.events.size());
|
|
315
|
+
NotificationEvent event = capture.events.get(0);
|
|
316
|
+
assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
|
|
317
|
+
assertEquals(id, event.getNotification().getId());
|
|
318
|
+
assertNotNull(event.getExtras());
|
|
319
|
+
assertEquals(startId, event.getExtras().getInt("startId"));
|
|
320
|
+
assertEquals(fgsType, event.getExtras().getInt("fgsType"));
|
|
321
|
+
} finally {
|
|
322
|
+
EventBus.unregister(capture);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
assertNull(ForegroundService.mCurrentNotificationId);
|
|
326
|
+
assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Defensive behaviour: if onTimeout fires on a service instance whose static state has already
|
|
331
|
+
* been cleared (a race between STOP and the Android system delivering a delayed timeout), the
|
|
332
|
+
* handler must not crash and must not post a stray event.
|
|
333
|
+
*/
|
|
334
|
+
@Test
|
|
335
|
+
@Config(sdk = 34)
|
|
336
|
+
public void onTimeout_withNoActiveState_doesNotCrashOrEmitEvent() {
|
|
337
|
+
ServiceController<ForegroundService> controller =
|
|
338
|
+
Robolectric.buildService(ForegroundService.class);
|
|
339
|
+
ForegroundService service = controller.create().get();
|
|
340
|
+
|
|
341
|
+
FgsEventCapture capture = new FgsEventCapture();
|
|
342
|
+
EventBus.register(capture);
|
|
343
|
+
try {
|
|
344
|
+
service.onTimeout(/* startId= */ 99);
|
|
345
|
+
assertEquals(0, capture.events.size());
|
|
346
|
+
} finally {
|
|
347
|
+
EventBus.unregister(capture);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
352
|
+
// Helpers
|
|
353
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
private static void createChannel() {
|
|
356
|
+
Context context = RuntimeEnvironment.getApplication();
|
|
357
|
+
NotificationManager nm =
|
|
358
|
+
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
359
|
+
if (nm != null && nm.getNotificationChannel(TEST_CHANNEL_ID) == null) {
|
|
360
|
+
NotificationChannel channel =
|
|
361
|
+
new NotificationChannel(
|
|
362
|
+
TEST_CHANNEL_ID, "FGS test channel", NotificationManager.IMPORTANCE_LOW);
|
|
363
|
+
nm.createNotificationChannel(channel);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private static Bundle buildNotificationBundle(String id) {
|
|
368
|
+
Bundle bundle = new Bundle();
|
|
369
|
+
bundle.putString("id", id);
|
|
370
|
+
bundle.putString("title", "FGS test " + id);
|
|
371
|
+
Bundle androidBundle = new Bundle();
|
|
372
|
+
androidBundle.putString("channelId", TEST_CHANNEL_ID);
|
|
373
|
+
bundle.putBundle("android", androidBundle);
|
|
374
|
+
return bundle;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private static Notification buildNotification() {
|
|
378
|
+
Context context = RuntimeEnvironment.getApplication();
|
|
379
|
+
return new NotificationCompat.Builder(context, TEST_CHANNEL_ID)
|
|
380
|
+
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
381
|
+
.setContentTitle("FGS test")
|
|
382
|
+
.build();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private static Intent buildStartIntent(String id, int hashCode) {
|
|
386
|
+
Intent intent = new Intent();
|
|
387
|
+
intent.setAction(ForegroundService.START_FOREGROUND_SERVICE_ACTION);
|
|
388
|
+
intent.putExtra("hashCode", hashCode);
|
|
389
|
+
intent.putExtra("notification", buildNotification());
|
|
390
|
+
intent.putExtra("notificationBundle", buildNotificationBundle(id));
|
|
391
|
+
return intent;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Populates the service's private static tracking fields directly, simulating the post-START
|
|
396
|
+
* state that onTimeout expects to observe. Using reflection here (rather than running a full
|
|
397
|
+
* START first) keeps the onTimeout tests SDK-independent — the START path would trip the API 34+
|
|
398
|
+
* manifest-type check, but onTimeout itself is sdk-agnostic because its body only uses pre-API-34
|
|
399
|
+
* primitives.
|
|
400
|
+
*/
|
|
401
|
+
private static void seedActiveForegroundServiceState(String id) throws Exception {
|
|
402
|
+
ForegroundService.mCurrentNotificationId = id;
|
|
403
|
+
ForegroundService.mCurrentForegroundServiceType = 1;
|
|
404
|
+
setPrivateStatic("mCurrentNotificationBundle", buildNotificationBundle(id));
|
|
405
|
+
setPrivateStatic("mCurrentNotification", buildNotification());
|
|
406
|
+
setPrivateStatic("mCurrentHashCode", id.hashCode());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private static void setPrivateStatic(String name, Object value) throws Exception {
|
|
410
|
+
Field field = ForegroundService.class.getDeclaredField(name);
|
|
411
|
+
field.setAccessible(true);
|
|
412
|
+
field.set(null, value);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* greenrobot EventBus subscriber that records every {@link NotificationEvent} posted during a
|
|
417
|
+
* test. {@link ThreadMode#POSTING} fires the subscriber synchronously on the caller thread, so
|
|
418
|
+
* the test can inspect {@code events} immediately after {@code post()} returns without any looper
|
|
419
|
+
* advancement.
|
|
420
|
+
*/
|
|
421
|
+
public static class FgsEventCapture {
|
|
422
|
+
final List<NotificationEvent> events = new ArrayList<>();
|
|
423
|
+
|
|
424
|
+
@Subscribe(threadMode = ThreadMode.POSTING)
|
|
425
|
+
public void onNotificationEvent(NotificationEvent event) {
|
|
426
|
+
events.add(event);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
182
429
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import static org.junit.Assert.assertFalse;
|
|
20
|
+
import static org.junit.Assert.assertTrue;
|
|
21
|
+
|
|
22
|
+
import org.junit.Test;
|
|
23
|
+
import org.junit.runner.RunWith;
|
|
24
|
+
import org.robolectric.RobolectricTestRunner;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unit tests for the BOOT_COUNT-based cold-start reschedule decision logic added for upstream
|
|
28
|
+
* invertase/notifee#734. Exhaustively covers the pure decision helper {@link
|
|
29
|
+
* InitProvider#shouldRescheduleAfterBoot}.
|
|
30
|
+
*
|
|
31
|
+
* <p>The integration path {@code runBootCheck → rescheduleNotifications} is intentionally not
|
|
32
|
+
* exercised here because {@code rescheduleNotifications} touches a real Room database via {@code
|
|
33
|
+
* WorkDataRepository.getInstance}, which is not set up in this unit-test environment. That path is
|
|
34
|
+
* covered by the instrumented {@code RebootRecoveryTest} and by the manual Step 4 smoke test.
|
|
35
|
+
*/
|
|
36
|
+
@RunWith(RobolectricTestRunner.class)
|
|
37
|
+
public class InitProviderBootCheckTest {
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
public void shouldRescheduleAfterBoot_firstRun_withValidBootCount_returnsFalse() {
|
|
41
|
+
// lastKnown == -1 means "no baseline recorded yet". We record the baseline
|
|
42
|
+
// and skip reschedule to avoid firing stale triggers on fresh installs.
|
|
43
|
+
assertFalse(InitProvider.shouldRescheduleAfterBoot(5, -1));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
public void shouldRescheduleAfterBoot_sameBoot_returnsFalse() {
|
|
48
|
+
// Same app process, same device boot → nothing to recover.
|
|
49
|
+
assertFalse(InitProvider.shouldRescheduleAfterBoot(5, 5));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
public void shouldRescheduleAfterBoot_newBoot_returnsTrue() {
|
|
54
|
+
// Reboot detected since last run → recover scheduled alarms.
|
|
55
|
+
assertTrue(InitProvider.shouldRescheduleAfterBoot(6, 5));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
public void shouldRescheduleAfterBoot_bootCountDecreased_returnsTrue() {
|
|
60
|
+
// BOOT_COUNT decreasing is unexpected but possible (factory reset, counter
|
|
61
|
+
// rollover on exotic ROMs). Any change is treated as "unknown transition
|
|
62
|
+
// since last run" → conservative reschedule.
|
|
63
|
+
assertTrue(InitProvider.shouldRescheduleAfterBoot(3, 5));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
public void shouldRescheduleAfterBoot_bootCountUnavailable_returnsTrue() {
|
|
68
|
+
// BOOT_COUNT couldn't be read → we can't tell if a reboot happened, so we
|
|
69
|
+
// reschedule conservatively. This also covers emulators and rooted ROMs
|
|
70
|
+
// where the Settings.Global row is missing or throws.
|
|
71
|
+
assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, 5));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
public void shouldRescheduleAfterBoot_bootCountUnavailable_andFirstRun_returnsTrue() {
|
|
76
|
+
// Degenerate case: first run AND BOOT_COUNT unavailable. The "unavailable"
|
|
77
|
+
// branch wins over the "first run" branch because we genuinely don't know
|
|
78
|
+
// if the device has rebooted since install.
|
|
79
|
+
assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, -1));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import static org.junit.Assert.assertNotNull;
|
|
20
|
+
import static org.junit.Assert.assertSame;
|
|
21
|
+
import static org.junit.Assert.fail;
|
|
22
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
23
|
+
import static org.mockito.ArgumentMatchers.anyString;
|
|
24
|
+
import static org.mockito.Mockito.mock;
|
|
25
|
+
import static org.mockito.Mockito.mockStatic;
|
|
26
|
+
import static org.mockito.Mockito.never;
|
|
27
|
+
import static org.mockito.Mockito.times;
|
|
28
|
+
import static org.mockito.Mockito.verify;
|
|
29
|
+
import static org.mockito.Mockito.when;
|
|
30
|
+
|
|
31
|
+
import android.os.Bundle;
|
|
32
|
+
import app.notifee.core.database.WorkDataRepository;
|
|
33
|
+
import app.notifee.core.model.NotificationModel;
|
|
34
|
+
import app.notifee.core.model.TimestampTriggerModel;
|
|
35
|
+
import com.google.common.util.concurrent.Futures;
|
|
36
|
+
import com.google.common.util.concurrent.ListenableFuture;
|
|
37
|
+
import com.google.common.util.concurrent.MoreExecutors;
|
|
38
|
+
import java.util.concurrent.ExecutionException;
|
|
39
|
+
import java.util.concurrent.Executor;
|
|
40
|
+
import java.util.concurrent.TimeUnit;
|
|
41
|
+
import org.junit.After;
|
|
42
|
+
import org.junit.Before;
|
|
43
|
+
import org.junit.Test;
|
|
44
|
+
import org.junit.runner.RunWith;
|
|
45
|
+
import org.mockito.MockedStatic;
|
|
46
|
+
import org.robolectric.RobolectricTestRunner;
|
|
47
|
+
import org.robolectric.RuntimeEnvironment;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Regression guards for the {@code NotifeeAlarmManager.handleStaleNonRepeatingTrigger} resilience
|
|
51
|
+
* chain introduced for upstream invertase/notifee#734 and hardened in Step 7 after a post-Step-6
|
|
52
|
+
* code review flagged two latent holes:
|
|
53
|
+
*
|
|
54
|
+
* <ol>
|
|
55
|
+
* <li><b>HIGH #1</b> — {@code Futures.catchingAsync(..., Throwable.class, ...)} was swallowing
|
|
56
|
+
* {@link Error} subclasses including {@link OutOfMemoryError}. On a memory-pressured cold
|
|
57
|
+
* boot (the exact target scenario of #734) this would mask a JVM-level failure and cause the
|
|
58
|
+
* handler to proceed to a Room write seconds before process termination. Narrowed to {@link
|
|
59
|
+
* Exception} so Errors propagate as batch failures.
|
|
60
|
+
* <li><b>MEDIUM #1</b> — if {@code NotificationManager.displayNotification} threw a synchronous
|
|
61
|
+
* exception before returning its {@link ListenableFuture} (e.g. NPE from {@code
|
|
62
|
+
* NotificationModel.getAndroid()} upstream of the work Callable), the throw would bypass
|
|
63
|
+
* {@code catchingAsync} entirely because the primary input future did not yet exist. Wrapping
|
|
64
|
+
* the call in {@link Futures#submitAsync} converts any sync throw from the {@code
|
|
65
|
+
* AsyncCallable} into a failed future the catching branch can observe.
|
|
66
|
+
* </ol>
|
|
67
|
+
*
|
|
68
|
+
* <p>Test strategy: {@link MockedStatic} intercepts both {@code NotificationManager} and {@code
|
|
69
|
+
* WorkDataRepository} static calls so the helper can be exercised in isolation without a real
|
|
70
|
+
* Android notification channel, a real notification post, or a real Room database. Each test stubs
|
|
71
|
+
* {@code displayNotification} with a specific failure shape and asserts whether {@code deleteById}
|
|
72
|
+
* is invoked.
|
|
73
|
+
*
|
|
74
|
+
* <p>Mockito 5.x's default {@link org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker}
|
|
75
|
+
* makes {@code mockStatic} available without an explicit {@code mockito-inline} dependency — the
|
|
76
|
+
* fork already uses {@code mockito-core:5.19.0} per {@code build.gradle:154}.
|
|
77
|
+
*/
|
|
78
|
+
@RunWith(RobolectricTestRunner.class)
|
|
79
|
+
public class NotifeeAlarmManagerHandleStaleTest {
|
|
80
|
+
|
|
81
|
+
/** 1 hour in the past — well inside the 24h STALE_TRIGGER_GRACE_PERIOD_MS window. */
|
|
82
|
+
private static final long STALE_WITHIN_GRACE_OFFSET_MS = 60L * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Same-thread executor. Required because Mockito {@link MockedStatic} stubs are thread-local to
|
|
86
|
+
* the thread that activated them — if the production code dispatched the {@code
|
|
87
|
+
* submitAsync}/{@code catchingAsync}/{@code transformAsync} chain to a worker thread pool, the
|
|
88
|
+
* real {@code NotificationManager.displayNotification} would be called instead of the stub,
|
|
89
|
+
* masking the exact regression this test is guarding against. Passing the directExecutor into the
|
|
90
|
+
* {@code handleStaleNonRepeatingTrigger(..., Executor)} overload keeps the whole chain on the
|
|
91
|
+
* test thread where the stubs are active.
|
|
92
|
+
*/
|
|
93
|
+
private static final Executor DIRECT_EXECUTOR = MoreExecutors.directExecutor();
|
|
94
|
+
|
|
95
|
+
private MockedStatic<NotificationManager> notificationManagerMock;
|
|
96
|
+
private MockedStatic<WorkDataRepository> workDataRepositoryMock;
|
|
97
|
+
private WorkDataRepository mockRepo;
|
|
98
|
+
|
|
99
|
+
@Before
|
|
100
|
+
public void setUp() {
|
|
101
|
+
// ContextHolder is consulted inside handleStaleNonRepeatingTrigger via
|
|
102
|
+
// WorkDataRepository.getInstance(getApplicationContext()) — populate with Robolectric's
|
|
103
|
+
// application context so the static call does not NPE before our mock intercepts it.
|
|
104
|
+
ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
|
|
105
|
+
|
|
106
|
+
mockRepo = mock(WorkDataRepository.class);
|
|
107
|
+
// deleteById is the terminal step of the resilient chain. Default stub returns an immediate
|
|
108
|
+
// success so the outer future completes cleanly; individual tests can override.
|
|
109
|
+
when(mockRepo.deleteById(anyString())).thenReturn(Futures.immediateFuture(null));
|
|
110
|
+
|
|
111
|
+
workDataRepositoryMock = mockStatic(WorkDataRepository.class);
|
|
112
|
+
workDataRepositoryMock.when(() -> WorkDataRepository.getInstance(any())).thenReturn(mockRepo);
|
|
113
|
+
|
|
114
|
+
notificationManagerMock = mockStatic(NotificationManager.class);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@After
|
|
118
|
+
public void tearDown() {
|
|
119
|
+
if (notificationManagerMock != null) {
|
|
120
|
+
notificationManagerMock.close();
|
|
121
|
+
}
|
|
122
|
+
if (workDataRepositoryMock != null) {
|
|
123
|
+
workDataRepositoryMock.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Test 3.1: Exception in display → row deleted (happy-resilience path) ───
|
|
128
|
+
|
|
129
|
+
@Test
|
|
130
|
+
public void handleStaleNonRepeatingTrigger_displayFailsWithRuntimeException_rowIsDeleted()
|
|
131
|
+
throws Exception {
|
|
132
|
+
notificationManagerMock
|
|
133
|
+
.when(() -> NotificationManager.displayNotification(any(), any()))
|
|
134
|
+
.thenReturn(Futures.immediateFailedFuture(new RuntimeException("synthetic display fail")));
|
|
135
|
+
|
|
136
|
+
NotificationModel model = buildStaleModel("test-runtime-exception");
|
|
137
|
+
TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
|
|
138
|
+
|
|
139
|
+
ListenableFuture<Void> result =
|
|
140
|
+
NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
|
|
141
|
+
|
|
142
|
+
assertNotNull("helper must return a non-null future for a stale trigger", result);
|
|
143
|
+
// Must complete without throwing — catchingAsync swallows the Exception and proceeds.
|
|
144
|
+
result.get(5, TimeUnit.SECONDS);
|
|
145
|
+
verify(mockRepo, times(1)).deleteById("test-runtime-exception");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Test 3.2: Error in display → row NOT deleted, Error propagates ──────────
|
|
149
|
+
// This is the HIGH #1 regression guard. Before the fix, Throwable.class
|
|
150
|
+
// would have caught the OOM and proceeded to deleteById. After the fix,
|
|
151
|
+
// Exception.class lets Errors propagate so the per-entity catch in
|
|
152
|
+
// rescheduleNotifications leaves the row in Room for a real retry.
|
|
153
|
+
|
|
154
|
+
@Test
|
|
155
|
+
public void handleStaleNonRepeatingTrigger_displayFailsWithError_rowIsNotDeleted_errorPropagates()
|
|
156
|
+
throws Exception {
|
|
157
|
+
OutOfMemoryError oom = new OutOfMemoryError("synthetic OOM");
|
|
158
|
+
notificationManagerMock
|
|
159
|
+
.when(() -> NotificationManager.displayNotification(any(), any()))
|
|
160
|
+
.thenReturn(Futures.immediateFailedFuture(oom));
|
|
161
|
+
|
|
162
|
+
NotificationModel model = buildStaleModel("test-oom");
|
|
163
|
+
TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
|
|
164
|
+
|
|
165
|
+
ListenableFuture<Void> result =
|
|
166
|
+
NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
|
|
167
|
+
|
|
168
|
+
assertNotNull("helper must return a non-null future for a stale trigger", result);
|
|
169
|
+
|
|
170
|
+
ExecutionException thrown = null;
|
|
171
|
+
try {
|
|
172
|
+
result.get(5, TimeUnit.SECONDS);
|
|
173
|
+
fail("Error must propagate as ExecutionException — not be swallowed by catchingAsync");
|
|
174
|
+
} catch (ExecutionException e) {
|
|
175
|
+
thrown = e;
|
|
176
|
+
}
|
|
177
|
+
assertNotNull(thrown);
|
|
178
|
+
assertSame(
|
|
179
|
+
"underlying cause must be the original OutOfMemoryError, not a wrapped RuntimeException",
|
|
180
|
+
oom,
|
|
181
|
+
thrown.getCause());
|
|
182
|
+
|
|
183
|
+
verify(mockRepo, never()).deleteById(anyString());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Test 3.3: Sync throw in display → row deleted via submitAsync wrap ─────
|
|
187
|
+
// This is the MEDIUM #1 regression guard. Before the fix, a sync throw
|
|
188
|
+
// upstream of the Callable would bypass catchingAsync because the primary
|
|
189
|
+
// future never existed. After the fix, Futures.submitAsync intercepts the
|
|
190
|
+
// sync throw and converts it into a failed future catchingAsync can catch.
|
|
191
|
+
|
|
192
|
+
@Test
|
|
193
|
+
public void handleStaleNonRepeatingTrigger_syncThrowInDisplay_rowIsDeleted_viaSubmitAsyncWrap()
|
|
194
|
+
throws Exception {
|
|
195
|
+
notificationManagerMock
|
|
196
|
+
.when(() -> NotificationManager.displayNotification(any(), any()))
|
|
197
|
+
.thenThrow(new IllegalStateException("synthetic sync throw before future creation"));
|
|
198
|
+
|
|
199
|
+
NotificationModel model = buildStaleModel("test-sync-throw");
|
|
200
|
+
TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
|
|
201
|
+
|
|
202
|
+
ListenableFuture<Void> result =
|
|
203
|
+
NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
|
|
204
|
+
|
|
205
|
+
assertNotNull("helper must return a non-null future for a stale trigger", result);
|
|
206
|
+
// Must complete without throwing — submitAsync converts the sync throw into a failed
|
|
207
|
+
// future, catchingAsync intercepts, and the chain proceeds to deleteById.
|
|
208
|
+
result.get(5, TimeUnit.SECONDS);
|
|
209
|
+
verify(mockRepo, times(1)).deleteById("test-sync-throw");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Builders ──────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
private static NotificationModel buildStaleModel(String id) {
|
|
215
|
+
Bundle notificationBundle = new Bundle();
|
|
216
|
+
notificationBundle.putString("id", id);
|
|
217
|
+
notificationBundle.putString("title", "HandleStaleTest " + id);
|
|
218
|
+
|
|
219
|
+
// A well-formed android sub-bundle is included so the path that the mock intercepts never
|
|
220
|
+
// touches a malformed NotificationModel — the test is exclusively about the resilient chain
|
|
221
|
+
// in handleStaleNonRepeatingTrigger, not about NotificationModel shape validation.
|
|
222
|
+
Bundle androidBundle = new Bundle();
|
|
223
|
+
androidBundle.putString("channelId", "handle-stale-test-channel");
|
|
224
|
+
notificationBundle.putBundle("android", androidBundle);
|
|
225
|
+
|
|
226
|
+
return NotificationModel.fromBundle(notificationBundle);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private static TimestampTriggerModel buildStaleWithinGraceTrigger() {
|
|
230
|
+
long anchorMs = System.currentTimeMillis() - STALE_WITHIN_GRACE_OFFSET_MS;
|
|
231
|
+
|
|
232
|
+
Bundle triggerBundle = new Bundle();
|
|
233
|
+
triggerBundle.putInt("type", 0); // TIMESTAMP
|
|
234
|
+
triggerBundle.putLong("timestamp", anchorMs);
|
|
235
|
+
triggerBundle.putInt("repeatFrequency", -1); // non-repeating
|
|
236
|
+
Bundle alarmManagerBundle = new Bundle();
|
|
237
|
+
alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
|
|
238
|
+
triggerBundle.putBundle("alarmManager", alarmManagerBundle);
|
|
239
|
+
|
|
240
|
+
return TimestampTriggerModel.fromBundle(triggerBundle);
|
|
241
|
+
}
|
|
242
|
+
}
|