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
|
@@ -28,9 +28,12 @@ import android.os.Build;
|
|
|
28
28
|
import android.os.Bundle;
|
|
29
29
|
import androidx.annotation.NonNull;
|
|
30
30
|
import androidx.annotation.Nullable;
|
|
31
|
+
import androidx.annotation.VisibleForTesting;
|
|
31
32
|
import androidx.core.app.AlarmManagerCompat;
|
|
32
33
|
import app.notifee.core.database.WorkDataEntity;
|
|
33
34
|
import app.notifee.core.database.WorkDataRepository;
|
|
35
|
+
import app.notifee.core.event.NotificationEvent;
|
|
36
|
+
import app.notifee.core.model.NotificationAndroidModel;
|
|
34
37
|
import app.notifee.core.model.NotificationModel;
|
|
35
38
|
import app.notifee.core.model.TimestampTriggerModel;
|
|
36
39
|
import app.notifee.core.utility.AlarmUtils;
|
|
@@ -44,11 +47,13 @@ import com.google.common.util.concurrent.MoreExecutors;
|
|
|
44
47
|
import java.util.ArrayList;
|
|
45
48
|
import java.util.Arrays;
|
|
46
49
|
import java.util.List;
|
|
50
|
+
import java.util.concurrent.Executor;
|
|
47
51
|
import java.util.concurrent.ExecutorService;
|
|
48
52
|
import java.util.concurrent.Executors;
|
|
49
53
|
import java.util.concurrent.ScheduledExecutorService;
|
|
50
54
|
import java.util.concurrent.TimeUnit;
|
|
51
55
|
import java.util.concurrent.TimeoutException;
|
|
56
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
52
57
|
|
|
53
58
|
class NotifeeAlarmManager {
|
|
54
59
|
private static final String TAG = "NotifeeAlarmManager";
|
|
@@ -57,6 +62,27 @@ class NotifeeAlarmManager {
|
|
|
57
62
|
private static final ListeningExecutorService alarmManagerListeningExecutor =
|
|
58
63
|
MoreExecutors.listeningDecorator(alarmManagerExecutor);
|
|
59
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Grace period for stale non-repeating triggers discovered during reboot recovery. Within this
|
|
67
|
+
* window the trigger is fired once and the Room row is deleted; beyond it the row is deleted
|
|
68
|
+
* silently to avoid showing stale content. Fix for upstream invertase/notifee#734: on OEM devices
|
|
69
|
+
* (MIUI, ColorOS, EMUI, FuntouchOS) that suppress BOOT_COMPLETED, zombie rows would otherwise
|
|
70
|
+
* re-fire on every reboot and never be cleaned.
|
|
71
|
+
*/
|
|
72
|
+
private static final long STALE_TRIGGER_GRACE_PERIOD_MS = TimeUnit.HOURS.toMillis(24);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process-wide guard against concurrent reschedule passes. Set via {@code compareAndSet} at the
|
|
76
|
+
* entry of {@link #rescheduleNotifications} and cleared on every terminal path. Prevents the
|
|
77
|
+
* double-advancement race that would occur if {@code RebootBroadcastReceiver} (triggered by
|
|
78
|
+
* BOOT_COMPLETED) and {@code InitProvider} (triggered by the BOOT_COUNT cold-start recovery added
|
|
79
|
+
* for upstream invertase/notifee#734) both ran {@code rescheduleNotifications} in parallel: for
|
|
80
|
+
* past-timestamp repeating triggers, {@code setNextTimestamp} advances the anchor in-place, so
|
|
81
|
+
* two concurrent passes would skip a full period. Duplicate concurrent requests are logged and
|
|
82
|
+
* dropped — the winning pass owns the reschedule cycle to completion.
|
|
83
|
+
*/
|
|
84
|
+
private static final AtomicBoolean rescheduleInProgress = new AtomicBoolean(false);
|
|
85
|
+
|
|
60
86
|
// Scheduler used only as the timeout clock for Futures.withTimeout on Room
|
|
61
87
|
// writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
|
|
62
88
|
// ~10s before Android kills the process, so we cap the Room wait at 8s and
|
|
@@ -71,10 +97,15 @@ class NotifeeAlarmManager {
|
|
|
71
97
|
* from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
|
|
72
98
|
* within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
|
|
73
99
|
* {@code logContext}; real failures at {@code ERROR}.
|
|
100
|
+
*
|
|
101
|
+
* <p>If {@code beforeFinish} is non-null it is executed immediately before {@code
|
|
102
|
+
* pendingResult.finish()} on both the success and failure paths. Callers use this hook to release
|
|
103
|
+
* any process-wide state they acquired before dispatching (e.g. the reschedule lock).
|
|
74
104
|
*/
|
|
75
105
|
private static void finishReceiverWhenDone(
|
|
76
106
|
@NonNull ListenableFuture<?> future,
|
|
77
107
|
@Nullable BroadcastReceiver.PendingResult pendingResult,
|
|
108
|
+
@Nullable Runnable beforeFinish,
|
|
78
109
|
@NonNull String logContext) {
|
|
79
110
|
ListenableFuture<?> bounded =
|
|
80
111
|
Futures.withTimeout(
|
|
@@ -84,6 +115,9 @@ class NotifeeAlarmManager {
|
|
|
84
115
|
new FutureCallback<Object>() {
|
|
85
116
|
@Override
|
|
86
117
|
public void onSuccess(Object result) {
|
|
118
|
+
if (beforeFinish != null) {
|
|
119
|
+
beforeFinish.run();
|
|
120
|
+
}
|
|
87
121
|
if (pendingResult != null) {
|
|
88
122
|
pendingResult.finish();
|
|
89
123
|
}
|
|
@@ -102,6 +136,9 @@ class NotifeeAlarmManager {
|
|
|
102
136
|
} else {
|
|
103
137
|
Logger.e(TAG, "Failure in " + logContext, new Exception(t));
|
|
104
138
|
}
|
|
139
|
+
if (beforeFinish != null) {
|
|
140
|
+
beforeFinish.run();
|
|
141
|
+
}
|
|
105
142
|
if (pendingResult != null) {
|
|
106
143
|
pendingResult.finish();
|
|
107
144
|
}
|
|
@@ -182,7 +219,7 @@ class NotifeeAlarmManager {
|
|
|
182
219
|
// Awaits the Room write before pendingResult.finish(), bounded by the 8s
|
|
183
220
|
// ANR safety timeout. Handles success, failure, and timeout uniformly.
|
|
184
221
|
finishReceiverWhenDone(
|
|
185
|
-
displayAndPersistFuture, pendingResult, "displayScheduledNotification[" + id + "]");
|
|
222
|
+
displayAndPersistFuture, pendingResult, null, "displayScheduledNotification[" + id + "]");
|
|
186
223
|
}
|
|
187
224
|
|
|
188
225
|
public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
|
|
@@ -268,21 +305,23 @@ class NotifeeAlarmManager {
|
|
|
268
305
|
pendingIntent);
|
|
269
306
|
break;
|
|
270
307
|
case SET_ALARM_CLOCK:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
308
|
+
// Build the "show intent" required by AlarmClockInfo — the PendingIntent Android
|
|
309
|
+
// fires when the user taps the alarm-clock icon rendered in the status bar while
|
|
310
|
+
// this alarm is pending. Reuse NotificationPendingIntent.createIntent so the tap
|
|
311
|
+
// funnels through the same NotificationReceiverActivity → onForegroundEvent path
|
|
312
|
+
// as a normal notification tap, honouring any custom pressAction.launchActivity /
|
|
313
|
+
// mainComponent the user configured. Building a getLaunchIntentForPackage ad-hoc
|
|
314
|
+
// (as the upstream PR #749 did) bypasses the pressAction fixes added in 9.1.19 /
|
|
315
|
+
// 9.3.0 and strands custom routing.
|
|
316
|
+
Bundle showIntentPressAction = buildShowIntentPressActionBundle(notificationModel);
|
|
280
317
|
PendingIntent pendingLaunchIntent =
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
318
|
+
NotificationPendingIntent.createIntent(
|
|
319
|
+
notificationModel.getHashCode(),
|
|
320
|
+
showIntentPressAction,
|
|
321
|
+
NotificationEvent.TYPE_PRESS,
|
|
322
|
+
new String[] {"notification"},
|
|
323
|
+
notificationModel.toBundle());
|
|
324
|
+
|
|
286
325
|
AlarmManagerCompat.setAlarmClock(
|
|
287
326
|
alarmManager, timestampTrigger.getTimestamp(), pendingLaunchIntent, pendingIntent);
|
|
288
327
|
break;
|
|
@@ -300,6 +339,41 @@ class NotifeeAlarmManager {
|
|
|
300
339
|
}
|
|
301
340
|
}
|
|
302
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the pressAction bundle used to build the AlarmClockInfo show intent for {@link
|
|
344
|
+
* TimestampTriggerModel.AlarmType#SET_ALARM_CLOCK}. Mirrors the three-case logic in {@code
|
|
345
|
+
* NotificationManager.displayNotification} so the status-bar alarm-clock icon, when tapped, goes
|
|
346
|
+
* through the same {@code NotificationReceiverActivity} path as a normal tap:
|
|
347
|
+
*
|
|
348
|
+
* <ol>
|
|
349
|
+
* <li>pressAction absent → synthesize default {@code { id:'default', launchActivity:'default'
|
|
350
|
+
* }} so the tap opens the app (defense-in-depth for triggers rehydrated from Room after an
|
|
351
|
+
* app kill, which lose their pressAction bundle).
|
|
352
|
+
* <li>pressAction carries the {@link NotificationPendingIntent#PRESS_ACTION_OPT_OUT_ID}
|
|
353
|
+
* sentinel (user passed {@code pressAction: null} in JS) → still synthesize the default.
|
|
354
|
+
* Unlike a content intent, the status-bar alarm-clock icon has no "non-tappable" mode —
|
|
355
|
+
* Android requires a non-null show intent. Opening the launcher is the least-surprising
|
|
356
|
+
* fallback and matches stock Clock app behaviour.
|
|
357
|
+
* <li>pressAction is a normal bundle → pass through unchanged so custom {@code launchActivity}
|
|
358
|
+
* / {@code mainComponent} routing is honoured.
|
|
359
|
+
* </ol>
|
|
360
|
+
*/
|
|
361
|
+
@VisibleForTesting
|
|
362
|
+
static Bundle buildShowIntentPressActionBundle(NotificationModel notificationModel) {
|
|
363
|
+
NotificationAndroidModel androidModel = notificationModel.getAndroid();
|
|
364
|
+
Bundle pressAction = androidModel != null ? androidModel.getPressAction() : null;
|
|
365
|
+
|
|
366
|
+
if (pressAction == null
|
|
367
|
+
|| NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(pressAction.getString("id"))) {
|
|
368
|
+
Bundle defaultPressAction = new Bundle();
|
|
369
|
+
defaultPressAction.putString("id", "default");
|
|
370
|
+
defaultPressAction.putString("launchActivity", "default");
|
|
371
|
+
return defaultPressAction;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return pressAction;
|
|
375
|
+
}
|
|
376
|
+
|
|
303
377
|
ListenableFuture<List<WorkDataEntity>> getScheduledNotifications() {
|
|
304
378
|
WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
|
|
305
379
|
return workDataRepository.getAllWithAlarmManager(true);
|
|
@@ -359,6 +433,12 @@ class NotifeeAlarmManager {
|
|
|
359
433
|
return Futures.immediateFuture(null);
|
|
360
434
|
}
|
|
361
435
|
|
|
436
|
+
ListenableFuture<Void> staleResult =
|
|
437
|
+
handleStaleNonRepeatingTrigger(notificationModel, trigger);
|
|
438
|
+
if (staleResult != null) {
|
|
439
|
+
return staleResult;
|
|
440
|
+
}
|
|
441
|
+
|
|
362
442
|
scheduleTimestampTriggerNotification(notificationModel, trigger);
|
|
363
443
|
// Persist updated timestamp so next reboot starts from the correct anchor.
|
|
364
444
|
return WorkDataRepository.getInstance(getApplicationContext())
|
|
@@ -376,7 +456,133 @@ class NotifeeAlarmManager {
|
|
|
376
456
|
}
|
|
377
457
|
}
|
|
378
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Handles a stale non-repeating TIMESTAMP trigger found during reboot recovery. A trigger is
|
|
461
|
+
* "stale" when its {@code repeatFrequency} is {@code null} (one-shot) and its {@code timestamp}
|
|
462
|
+
* has already passed. Returns a {@link ListenableFuture} describing the stale-handling action, or
|
|
463
|
+
* {@code null} if the trigger is not stale and the caller should proceed with normal re-arming.
|
|
464
|
+
*
|
|
465
|
+
* <p>Within {@link #STALE_TRIGGER_GRACE_PERIOD_MS} of the original fire time the notification is
|
|
466
|
+
* fired once (late) and the Room row is deleted; beyond the grace period the row is deleted
|
|
467
|
+
* silently. In both cases the row is removed, preventing the zombie re-fire loop described in
|
|
468
|
+
* upstream invertase/notifee#734.
|
|
469
|
+
*
|
|
470
|
+
* <p>Package-private (not {@code private}) so that {@code NotifeeAlarmManagerHandleStaleTest} in
|
|
471
|
+
* {@code src/test/java/app/notifee/core/} can exercise the resilient display → delete chain via
|
|
472
|
+
* the {@code (..., Executor)} overload with {@code MoreExecutors.directExecutor()}.
|
|
473
|
+
*/
|
|
474
|
+
@Nullable
|
|
475
|
+
static ListenableFuture<Void> handleStaleNonRepeatingTrigger(
|
|
476
|
+
NotificationModel notificationModel, TimestampTriggerModel trigger) {
|
|
477
|
+
return handleStaleNonRepeatingTrigger(
|
|
478
|
+
notificationModel, trigger, alarmManagerListeningExecutor);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Testable overload of {@link #handleStaleNonRepeatingTrigger(NotificationModel,
|
|
483
|
+
* TimestampTriggerModel)} that accepts the {@link Executor} used for the resilient display →
|
|
484
|
+
* delete chain. Production callers use the no-executor overload which delegates with the
|
|
485
|
+
* cached-thread-pool listening executor. Unit tests pass {@code
|
|
486
|
+
* com.google.common.util.concurrent.MoreExecutors.directExecutor()} so that Mockito's
|
|
487
|
+
* thread-local {@code mockStatic} intercepts fire on the calling thread instead of a worker
|
|
488
|
+
* thread where the stubs are inactive.
|
|
489
|
+
*/
|
|
490
|
+
@VisibleForTesting
|
|
491
|
+
@Nullable
|
|
492
|
+
static ListenableFuture<Void> handleStaleNonRepeatingTrigger(
|
|
493
|
+
NotificationModel notificationModel, TimestampTriggerModel trigger, Executor executor) {
|
|
494
|
+
if (trigger.getRepeatFrequency() != null) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
long nowMs = System.currentTimeMillis();
|
|
498
|
+
long triggerTs = trigger.getTimestamp();
|
|
499
|
+
if (triggerTs >= nowMs) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
String id = notificationModel.getId();
|
|
504
|
+
WorkDataRepository workRepo = WorkDataRepository.getInstance(getApplicationContext());
|
|
505
|
+
long stalenessMs = nowMs - triggerTs;
|
|
506
|
+
|
|
507
|
+
if (stalenessMs > STALE_TRIGGER_GRACE_PERIOD_MS) {
|
|
508
|
+
Logger.i(
|
|
509
|
+
TAG,
|
|
510
|
+
"Deleting stale non-repeating trigger (age "
|
|
511
|
+
+ stalenessMs
|
|
512
|
+
+ "ms > "
|
|
513
|
+
+ STALE_TRIGGER_GRACE_PERIOD_MS
|
|
514
|
+
+ "ms grace period): "
|
|
515
|
+
+ id);
|
|
516
|
+
return workRepo.deleteById(id);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
Logger.i(
|
|
520
|
+
TAG,
|
|
521
|
+
"Firing stale non-repeating trigger once within grace period (age "
|
|
522
|
+
+ stalenessMs
|
|
523
|
+
+ "ms): "
|
|
524
|
+
+ id);
|
|
525
|
+
|
|
526
|
+
// Critical: even if the late-fire fails — because the target notification
|
|
527
|
+
// channel has been deleted between scheduling and recovery, because the
|
|
528
|
+
// serialized NotificationModel was written by an older library version
|
|
529
|
+
// whose shape differs, or because displayNotification throws for any other
|
|
530
|
+
// reason — we MUST still delete the Room row. Otherwise the zombie re-fire
|
|
531
|
+
// loop this helper is supposed to break persists across future reboots,
|
|
532
|
+
// exactly the scenario #734 is about. The within-grace path promises
|
|
533
|
+
// "attempt to fire, always clean"; the fire-once is best-effort, the delete
|
|
534
|
+
// is the correctness guarantee. Discovered via the Step 6 smoke dry-run,
|
|
535
|
+
// which surfaced a NullPointerException in NotificationAndroidModel.getChannelId
|
|
536
|
+
// and observed the chained deleteById never running.
|
|
537
|
+
//
|
|
538
|
+
// Post-Step-6 code review (Step 7) hardened the chain against two further
|
|
539
|
+
// failure modes the original catchingAsync missed:
|
|
540
|
+
//
|
|
541
|
+
// 1. Sync throws from NotificationManager.displayNotification (e.g. an
|
|
542
|
+
// NPE from NotificationModel.getAndroid() before the work Callable is
|
|
543
|
+
// even constructed) would bypass catchingAsync entirely, because the
|
|
544
|
+
// primary input future to catchingAsync did not yet exist. Wrapping
|
|
545
|
+
// the call in Futures.submitAsync converts any sync throw from the
|
|
546
|
+
// AsyncCallable into a failed future that catchingAsync can observe.
|
|
547
|
+
//
|
|
548
|
+
// 2. Throwable.class was too broad — it swallowed Error subclasses
|
|
549
|
+
// including OutOfMemoryError, VirtualMachineError, LinkageError,
|
|
550
|
+
// AssertionError. On a memory-pressured cold boot (the exact target
|
|
551
|
+
// scenario of #734) an OOM inside NotificationCompat.Builder.build()
|
|
552
|
+
// would be silently absorbed and the handler would proceed to a Room
|
|
553
|
+
// write while the JVM was seconds from termination. Narrowed to
|
|
554
|
+
// Exception.class so Errors propagate as batch failures; the
|
|
555
|
+
// per-entity catch in rescheduleNotifications leaves the row in Room
|
|
556
|
+
// for a genuine retry on the next reboot pass.
|
|
557
|
+
ListenableFuture<Void> displayAttempt =
|
|
558
|
+
Futures.submitAsync(
|
|
559
|
+
() -> NotificationManager.displayNotification(notificationModel, null), executor);
|
|
560
|
+
|
|
561
|
+
ListenableFuture<Void> resilientDisplay =
|
|
562
|
+
Futures.catchingAsync(
|
|
563
|
+
displayAttempt,
|
|
564
|
+
Exception.class,
|
|
565
|
+
t -> {
|
|
566
|
+
Logger.w(
|
|
567
|
+
TAG, "Late-fire of stale trigger " + id + " failed, proceeding to delete row", t);
|
|
568
|
+
return Futures.immediateFuture(null);
|
|
569
|
+
},
|
|
570
|
+
executor);
|
|
571
|
+
|
|
572
|
+
return Futures.transformAsync(resilientDisplay, ignored -> workRepo.deleteById(id), executor);
|
|
573
|
+
}
|
|
574
|
+
|
|
379
575
|
void rescheduleNotifications(@Nullable BroadcastReceiver.PendingResult pendingResult) {
|
|
576
|
+
if (!rescheduleInProgress.compareAndSet(false, true)) {
|
|
577
|
+
Logger.i(TAG, "Reschedule already in progress, skipping duplicate request");
|
|
578
|
+
if (pendingResult != null) {
|
|
579
|
+
pendingResult.finish();
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
final Runnable releaseLock = () -> rescheduleInProgress.set(false);
|
|
585
|
+
|
|
380
586
|
Logger.d(TAG, "Reschedule Notifications on reboot");
|
|
381
587
|
Futures.addCallback(
|
|
382
588
|
getScheduledNotifications(),
|
|
@@ -390,6 +596,7 @@ class NotifeeAlarmManager {
|
|
|
390
596
|
+ " recurring alarms");
|
|
391
597
|
|
|
392
598
|
if (workDataEntities == null || workDataEntities.isEmpty()) {
|
|
599
|
+
releaseLock.run();
|
|
393
600
|
if (pendingResult != null) {
|
|
394
601
|
pendingResult.finish();
|
|
395
602
|
}
|
|
@@ -415,14 +622,18 @@ class NotifeeAlarmManager {
|
|
|
415
622
|
// Awaits all per-entity update futures before finishing the boot
|
|
416
623
|
// receiver, bounded by the 8s ANR safety timeout. Any not-yet-
|
|
417
624
|
// persisted next-fire anchors left behind on timeout will catch up
|
|
418
|
-
// on the next alarm fire.
|
|
625
|
+
// on the next alarm fire. The releaseLock runnable is invoked from
|
|
626
|
+
// inside finishReceiverWhenDone's terminal callbacks (success,
|
|
627
|
+
// failure, or timeout), guaranteeing the CAS flag is cleared
|
|
628
|
+
// exactly once on every code path.
|
|
419
629
|
ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
|
|
420
|
-
finishReceiverWhenDone(combined, pendingResult, "rescheduleNotifications");
|
|
630
|
+
finishReceiverWhenDone(combined, pendingResult, releaseLock, "rescheduleNotifications");
|
|
421
631
|
}
|
|
422
632
|
|
|
423
633
|
@Override
|
|
424
634
|
public void onFailure(@NonNull Throwable t) {
|
|
425
635
|
Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
|
|
636
|
+
releaseLock.run();
|
|
426
637
|
if (pendingResult != null) {
|
|
427
638
|
pendingResult.finish();
|
|
428
639
|
}
|
|
@@ -29,14 +29,25 @@ import android.content.Intent;
|
|
|
29
29
|
* display chain completes. This is critical on Android 14+ when the app is killed.
|
|
30
30
|
*/
|
|
31
31
|
public class NotificationAlarmReceiver extends BroadcastReceiver {
|
|
32
|
+
private static final String TAG = "NotificationAlarmReceiver";
|
|
33
|
+
|
|
32
34
|
@Override
|
|
33
35
|
public void onReceive(Context context, Intent intent) {
|
|
34
36
|
PendingResult pendingResult = goAsync();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// See RebootBroadcastReceiver for the rationale behind this guard.
|
|
38
|
+
boolean asyncHandoffSucceeded = false;
|
|
39
|
+
try {
|
|
40
|
+
if (ContextHolder.getApplicationContext() == null) {
|
|
41
|
+
ContextHolder.setApplicationContext(context.getApplicationContext());
|
|
42
|
+
}
|
|
43
|
+
NotifeeAlarmManager.displayScheduledNotification(intent.getExtras(), pendingResult);
|
|
44
|
+
asyncHandoffSucceeded = true;
|
|
45
|
+
} catch (Throwable t) {
|
|
46
|
+
Logger.e(TAG, "Failed to display scheduled notification", t);
|
|
47
|
+
} finally {
|
|
48
|
+
if (!asyncHandoffSucceeded && pendingResult != null) {
|
|
49
|
+
pendingResult.finish();
|
|
50
|
+
}
|
|
38
51
|
}
|
|
39
|
-
|
|
40
|
-
NotifeeAlarmManager.displayScheduledNotification(intent.getExtras(), pendingResult);
|
|
41
52
|
}
|
|
42
53
|
}
|
|
@@ -22,6 +22,15 @@ import android.content.SharedPreferences;
|
|
|
22
22
|
|
|
23
23
|
class Preferences {
|
|
24
24
|
private static final String PREFERENCES_FILE = "app.notifee.core";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* SharedPreferences key storing the value of {@code Settings.Global.BOOT_COUNT} observed on the
|
|
28
|
+
* most recent successful app init. Compared against the current {@code BOOT_COUNT} to detect a
|
|
29
|
+
* reboot since the last app run — the core mechanism of the OEM cold-start recovery added for
|
|
30
|
+
* upstream invertase/notifee#734.
|
|
31
|
+
*/
|
|
32
|
+
static final String LAST_KNOWN_BOOT_COUNT_KEY = "notifee_last_known_boot_count";
|
|
33
|
+
|
|
25
34
|
private static Preferences sharedInstance = new Preferences();
|
|
26
35
|
private SharedPreferences preferences;
|
|
27
36
|
|
|
@@ -20,20 +20,35 @@ package app.notifee.core;
|
|
|
20
20
|
import android.content.BroadcastReceiver;
|
|
21
21
|
import android.content.Context;
|
|
22
22
|
import android.content.Intent;
|
|
23
|
-
import android.util.Log;
|
|
24
23
|
|
|
25
24
|
/*
|
|
26
25
|
* This is invoked when the phone restarts to ensure that all notifications created by the alarm manager
|
|
27
26
|
* are rescheduled correctly, as Android removes all scheduled alarms when the phone shuts down.
|
|
28
27
|
*/
|
|
29
28
|
public class RebootBroadcastReceiver extends BroadcastReceiver {
|
|
29
|
+
private static final String TAG = "RebootReceiver";
|
|
30
|
+
|
|
30
31
|
@Override
|
|
31
32
|
public void onReceive(Context context, Intent intent) {
|
|
32
33
|
PendingResult pendingResult = goAsync();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Tracks whether the synchronous section successfully handed off to the
|
|
35
|
+
// async reschedule path. If not, the finally block must call finish() to
|
|
36
|
+
// avoid leaving the broadcast unterminated — Android will otherwise kill
|
|
37
|
+
// the process after ~10s and race subsequent reboot broadcasts.
|
|
38
|
+
boolean asyncHandoffSucceeded = false;
|
|
39
|
+
try {
|
|
40
|
+
Logger.i(TAG, "Received reboot event");
|
|
41
|
+
if (ContextHolder.getApplicationContext() == null) {
|
|
42
|
+
ContextHolder.setApplicationContext(context.getApplicationContext());
|
|
43
|
+
}
|
|
44
|
+
new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
|
|
45
|
+
asyncHandoffSucceeded = true;
|
|
46
|
+
} catch (Throwable t) {
|
|
47
|
+
Logger.e(TAG, "Failed to reschedule notifications after reboot", t);
|
|
48
|
+
} finally {
|
|
49
|
+
if (!asyncHandoffSucceeded && pendingResult != null) {
|
|
50
|
+
pendingResult.finish();
|
|
51
|
+
}
|
|
36
52
|
}
|
|
37
|
-
new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
|
|
38
53
|
}
|
|
39
54
|
}
|
|
@@ -204,8 +204,9 @@ class HeadlessTask {
|
|
|
204
204
|
private val params: WritableMap
|
|
205
205
|
|
|
206
206
|
init {
|
|
207
|
-
params.
|
|
208
|
-
|
|
207
|
+
val copied = params.copy()
|
|
208
|
+
copied.putInt("taskId", taskId)
|
|
209
|
+
this.params = copied
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
val taskConfig: HeadlessJsTaskConfig
|