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
|
@@ -26,10 +26,14 @@ import android.content.Context;
|
|
|
26
26
|
import android.content.Intent;
|
|
27
27
|
import android.os.Build;
|
|
28
28
|
import android.os.Bundle;
|
|
29
|
+
import androidx.annotation.NonNull;
|
|
29
30
|
import androidx.annotation.Nullable;
|
|
31
|
+
import androidx.annotation.VisibleForTesting;
|
|
30
32
|
import androidx.core.app.AlarmManagerCompat;
|
|
31
33
|
import app.notifee.core.database.WorkDataEntity;
|
|
32
34
|
import app.notifee.core.database.WorkDataRepository;
|
|
35
|
+
import app.notifee.core.event.NotificationEvent;
|
|
36
|
+
import app.notifee.core.model.NotificationAndroidModel;
|
|
33
37
|
import app.notifee.core.model.NotificationModel;
|
|
34
38
|
import app.notifee.core.model.TimestampTriggerModel;
|
|
35
39
|
import app.notifee.core.utility.AlarmUtils;
|
|
@@ -40,10 +44,16 @@ import com.google.common.util.concurrent.Futures;
|
|
|
40
44
|
import com.google.common.util.concurrent.ListenableFuture;
|
|
41
45
|
import com.google.common.util.concurrent.ListeningExecutorService;
|
|
42
46
|
import com.google.common.util.concurrent.MoreExecutors;
|
|
47
|
+
import java.util.ArrayList;
|
|
43
48
|
import java.util.Arrays;
|
|
44
49
|
import java.util.List;
|
|
50
|
+
import java.util.concurrent.Executor;
|
|
45
51
|
import java.util.concurrent.ExecutorService;
|
|
46
52
|
import java.util.concurrent.Executors;
|
|
53
|
+
import java.util.concurrent.ScheduledExecutorService;
|
|
54
|
+
import java.util.concurrent.TimeUnit;
|
|
55
|
+
import java.util.concurrent.TimeoutException;
|
|
56
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
47
57
|
|
|
48
58
|
class NotifeeAlarmManager {
|
|
49
59
|
private static final String TAG = "NotifeeAlarmManager";
|
|
@@ -52,6 +62,91 @@ class NotifeeAlarmManager {
|
|
|
52
62
|
private static final ListeningExecutorService alarmManagerListeningExecutor =
|
|
53
63
|
MoreExecutors.listeningDecorator(alarmManagerExecutor);
|
|
54
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
|
+
|
|
86
|
+
// Scheduler used only as the timeout clock for Futures.withTimeout on Room
|
|
87
|
+
// writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
|
|
88
|
+
// ~10s before Android kills the process, so we cap the Room wait at 8s and
|
|
89
|
+
// call pendingResult.finish() anyway on timeout to avoid ANRs.
|
|
90
|
+
private static final ScheduledExecutorService TIMEOUT_SCHEDULER =
|
|
91
|
+
Executors.newSingleThreadScheduledExecutor();
|
|
92
|
+
private static final long RECEIVER_WRITE_TIMEOUT_SECONDS = 8;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wraps {@code future} in a {@link Futures#withTimeout} safety net and arranges for {@code
|
|
96
|
+
* pendingResult.finish()} to be called exactly once — on success, on failure, or on timeout. Use
|
|
97
|
+
* from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
|
|
98
|
+
* within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
|
|
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).
|
|
104
|
+
*/
|
|
105
|
+
private static void finishReceiverWhenDone(
|
|
106
|
+
@NonNull ListenableFuture<?> future,
|
|
107
|
+
@Nullable BroadcastReceiver.PendingResult pendingResult,
|
|
108
|
+
@Nullable Runnable beforeFinish,
|
|
109
|
+
@NonNull String logContext) {
|
|
110
|
+
ListenableFuture<?> bounded =
|
|
111
|
+
Futures.withTimeout(
|
|
112
|
+
future, RECEIVER_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS, TIMEOUT_SCHEDULER);
|
|
113
|
+
Futures.addCallback(
|
|
114
|
+
bounded,
|
|
115
|
+
new FutureCallback<Object>() {
|
|
116
|
+
@Override
|
|
117
|
+
public void onSuccess(Object result) {
|
|
118
|
+
if (beforeFinish != null) {
|
|
119
|
+
beforeFinish.run();
|
|
120
|
+
}
|
|
121
|
+
if (pendingResult != null) {
|
|
122
|
+
pendingResult.finish();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Override
|
|
127
|
+
public void onFailure(@NonNull Throwable t) {
|
|
128
|
+
if (t instanceof TimeoutException) {
|
|
129
|
+
Logger.w(
|
|
130
|
+
TAG,
|
|
131
|
+
"Room write for "
|
|
132
|
+
+ logContext
|
|
133
|
+
+ " did not complete within "
|
|
134
|
+
+ RECEIVER_WRITE_TIMEOUT_SECONDS
|
|
135
|
+
+ "s; finishing BroadcastReceiver anyway to avoid ANR");
|
|
136
|
+
} else {
|
|
137
|
+
Logger.e(TAG, "Failure in " + logContext, new Exception(t));
|
|
138
|
+
}
|
|
139
|
+
if (beforeFinish != null) {
|
|
140
|
+
beforeFinish.run();
|
|
141
|
+
}
|
|
142
|
+
if (pendingResult != null) {
|
|
143
|
+
pendingResult.finish();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
alarmManagerListeningExecutor);
|
|
148
|
+
}
|
|
149
|
+
|
|
55
150
|
static void displayScheduledNotification(
|
|
56
151
|
Bundle alarmManagerNotification, @Nullable BroadcastReceiver.PendingResult pendingResult) {
|
|
57
152
|
if (alarmManagerNotification == null) {
|
|
@@ -71,72 +166,60 @@ class NotifeeAlarmManager {
|
|
|
71
166
|
|
|
72
167
|
WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
|
|
73
168
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
true));
|
|
117
|
-
} else {
|
|
118
|
-
// not repeating, delete database entry if work is a one-time request
|
|
119
|
-
WorkDataRepository.getInstance(getApplicationContext())
|
|
120
|
-
.deleteById(id);
|
|
121
|
-
}
|
|
122
|
-
return Futures.immediateFuture(null);
|
|
123
|
-
},
|
|
124
|
-
alarmManagerExecutor);
|
|
125
|
-
},
|
|
126
|
-
alarmManagerExecutor)
|
|
127
|
-
.addOnCompleteListener(
|
|
128
|
-
(e, result) -> {
|
|
129
|
-
try {
|
|
130
|
-
if (e != null) {
|
|
131
|
-
Logger.e(TAG, "Failed to display notification", e);
|
|
132
|
-
}
|
|
133
|
-
} finally {
|
|
134
|
-
if (pendingResult != null) {
|
|
135
|
-
pendingResult.finish();
|
|
169
|
+
// Chain: read → display → persist (update for repeat / delete for one-shot).
|
|
170
|
+
// The final Room write is awaited before pendingResult.finish() so the
|
|
171
|
+
// BroadcastReceiver only tells Android "I'm done" once the next-fire anchor
|
|
172
|
+
// (or the deletion) has actually landed in Room. Without this, process death
|
|
173
|
+
// between finish() and the enqueued write could lose the updated timestamp
|
|
174
|
+
// and cause the same alarm to fire again on the next reboot. See #549 audit
|
|
175
|
+
// callers #6 and #7.
|
|
176
|
+
ListenableFuture<Void> displayAndPersistFuture =
|
|
177
|
+
Futures.transformAsync(
|
|
178
|
+
workDataRepository.getWorkDataById(id),
|
|
179
|
+
workDataEntity -> {
|
|
180
|
+
if (workDataEntity == null
|
|
181
|
+
|| workDataEntity.getNotification() == null
|
|
182
|
+
|| workDataEntity.getTrigger() == null) {
|
|
183
|
+
Logger.w(
|
|
184
|
+
TAG, "Attempted to handle doScheduledWork but no notification data was found.");
|
|
185
|
+
return Futures.immediateFuture(null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
Bundle triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
|
|
189
|
+
Bundle notificationBundle =
|
|
190
|
+
ObjectUtils.bytesToBundle(workDataEntity.getNotification());
|
|
191
|
+
NotificationModel notificationModel =
|
|
192
|
+
NotificationModel.fromBundle(notificationBundle);
|
|
193
|
+
|
|
194
|
+
return Futures.transformAsync(
|
|
195
|
+
NotificationManager.displayNotification(notificationModel, triggerBundle),
|
|
196
|
+
voidDisplayedNotification -> {
|
|
197
|
+
if (triggerBundle.containsKey("repeatFrequency")
|
|
198
|
+
&& ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
|
|
199
|
+
TimestampTriggerModel trigger =
|
|
200
|
+
TimestampTriggerModel.fromBundle(triggerBundle);
|
|
201
|
+
// scheduleTimestampTriggerNotification() calls setNextTimestamp()
|
|
202
|
+
// internally, so we must NOT call it here to avoid double-advancing
|
|
203
|
+
scheduleTimestampTriggerNotification(notificationModel, trigger);
|
|
204
|
+
return WorkDataRepository.getInstance(getApplicationContext())
|
|
205
|
+
.update(
|
|
206
|
+
new WorkDataEntity(
|
|
207
|
+
id,
|
|
208
|
+
workDataEntity.getNotification(),
|
|
209
|
+
ObjectUtils.bundleToBytes(trigger.toBundle()),
|
|
210
|
+
true));
|
|
136
211
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
212
|
+
// not repeating, delete database entry if work is a one-time request
|
|
213
|
+
return WorkDataRepository.getInstance(getApplicationContext()).deleteById(id);
|
|
214
|
+
},
|
|
215
|
+
alarmManagerExecutor);
|
|
216
|
+
},
|
|
217
|
+
alarmManagerExecutor);
|
|
218
|
+
|
|
219
|
+
// Awaits the Room write before pendingResult.finish(), bounded by the 8s
|
|
220
|
+
// ANR safety timeout. Handles success, failure, and timeout uniformly.
|
|
221
|
+
finishReceiverWhenDone(
|
|
222
|
+
displayAndPersistFuture, pendingResult, null, "displayScheduledNotification[" + id + "]");
|
|
140
223
|
}
|
|
141
224
|
|
|
142
225
|
public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
|
|
@@ -222,21 +305,23 @@ class NotifeeAlarmManager {
|
|
|
222
305
|
pendingIntent);
|
|
223
306
|
break;
|
|
224
307
|
case SET_ALARM_CLOCK:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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);
|
|
234
317
|
PendingIntent pendingLaunchIntent =
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
318
|
+
NotificationPendingIntent.createIntent(
|
|
319
|
+
notificationModel.getHashCode(),
|
|
320
|
+
showIntentPressAction,
|
|
321
|
+
NotificationEvent.TYPE_PRESS,
|
|
322
|
+
new String[] {"notification"},
|
|
323
|
+
notificationModel.toBundle());
|
|
324
|
+
|
|
240
325
|
AlarmManagerCompat.setAlarmClock(
|
|
241
326
|
alarmManager, timestampTrigger.getTimestamp(), pendingLaunchIntent, pendingIntent);
|
|
242
327
|
break;
|
|
@@ -254,6 +339,41 @@ class NotifeeAlarmManager {
|
|
|
254
339
|
}
|
|
255
340
|
}
|
|
256
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
|
+
|
|
257
377
|
ListenableFuture<List<WorkDataEntity>> getScheduledNotifications() {
|
|
258
378
|
WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
|
|
259
379
|
return workDataRepository.getAllWithAlarmManager(true);
|
|
@@ -283,10 +403,18 @@ class NotifeeAlarmManager {
|
|
|
283
403
|
alarmManagerListeningExecutor);
|
|
284
404
|
}
|
|
285
405
|
|
|
286
|
-
|
|
287
|
-
|
|
406
|
+
/**
|
|
407
|
+
* On reboot, reschedule one trigger notification created via alarm manager.
|
|
408
|
+
*
|
|
409
|
+
* <p>Returns a future that completes when Room has persisted the updated next-fire anchor. The
|
|
410
|
+
* caller MUST await this future before calling {@code pendingResult.finish()} — otherwise Android
|
|
411
|
+
* may kill the boot receiver's process before Room has drained, and the next reboot will
|
|
412
|
+
* reschedule from the stale anchor. This bug is NOT in upstream invertase/notifee#549 and was
|
|
413
|
+
* surfaced only by the pre-fix-549-audit.md read-only caller audit (Caller #8).
|
|
414
|
+
*/
|
|
415
|
+
ListenableFuture<Void> rescheduleNotification(WorkDataEntity workDataEntity) {
|
|
288
416
|
if (workDataEntity.getNotification() == null || workDataEntity.getTrigger() == null) {
|
|
289
|
-
return;
|
|
417
|
+
return Futures.immediateFuture(null);
|
|
290
418
|
}
|
|
291
419
|
|
|
292
420
|
byte[] notificationBytes = workDataEntity.getNotification();
|
|
@@ -302,46 +430,210 @@ class NotifeeAlarmManager {
|
|
|
302
430
|
case 0:
|
|
303
431
|
TimestampTriggerModel trigger = TimestampTriggerModel.fromBundle(triggerBundle);
|
|
304
432
|
if (!trigger.getWithAlarmManager()) {
|
|
305
|
-
return;
|
|
433
|
+
return Futures.immediateFuture(null);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
ListenableFuture<Void> staleResult =
|
|
437
|
+
handleStaleNonRepeatingTrigger(notificationModel, trigger);
|
|
438
|
+
if (staleResult != null) {
|
|
439
|
+
return staleResult;
|
|
306
440
|
}
|
|
307
441
|
|
|
308
442
|
scheduleTimestampTriggerNotification(notificationModel, trigger);
|
|
309
|
-
// Persist updated timestamp so next reboot starts from the correct anchor
|
|
310
|
-
WorkDataRepository.getInstance(getApplicationContext())
|
|
443
|
+
// Persist updated timestamp so next reboot starts from the correct anchor.
|
|
444
|
+
return WorkDataRepository.getInstance(getApplicationContext())
|
|
311
445
|
.update(
|
|
312
446
|
new WorkDataEntity(
|
|
313
447
|
workDataEntity.getId(),
|
|
314
448
|
workDataEntity.getNotification(),
|
|
315
449
|
ObjectUtils.bundleToBytes(trigger.toBundle()),
|
|
316
450
|
workDataEntity.getWithAlarmManager()));
|
|
317
|
-
break;
|
|
318
451
|
case 1:
|
|
319
452
|
// TODO: support interval triggers with alarm manager
|
|
320
|
-
|
|
453
|
+
return Futures.immediateFuture(null);
|
|
454
|
+
default:
|
|
455
|
+
return Futures.immediateFuture(null);
|
|
321
456
|
}
|
|
322
457
|
}
|
|
323
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
|
+
|
|
324
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
|
+
|
|
325
586
|
Logger.d(TAG, "Reschedule Notifications on reboot");
|
|
326
587
|
Futures.addCallback(
|
|
327
588
|
getScheduledNotifications(),
|
|
328
589
|
new FutureCallback<List<WorkDataEntity>>() {
|
|
329
590
|
@Override
|
|
330
591
|
public void onSuccess(List<WorkDataEntity> workDataEntities) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
592
|
+
Logger.d(
|
|
593
|
+
TAG,
|
|
594
|
+
"Reschedule starting for "
|
|
595
|
+
+ (workDataEntities != null ? workDataEntities.size() : 0)
|
|
596
|
+
+ " recurring alarms");
|
|
597
|
+
|
|
598
|
+
if (workDataEntities == null || workDataEntities.isEmpty()) {
|
|
599
|
+
releaseLock.run();
|
|
336
600
|
if (pendingResult != null) {
|
|
337
601
|
pendingResult.finish();
|
|
338
602
|
}
|
|
603
|
+
return;
|
|
339
604
|
}
|
|
605
|
+
|
|
606
|
+
List<ListenableFuture<Void>> updateFutures = new ArrayList<>(workDataEntities.size());
|
|
607
|
+
for (WorkDataEntity workDataEntity : workDataEntities) {
|
|
608
|
+
try {
|
|
609
|
+
updateFutures.add(rescheduleNotification(workDataEntity));
|
|
610
|
+
} catch (Throwable t) {
|
|
611
|
+
// A single bad entity must not prevent the rest of the batch
|
|
612
|
+
// from being rescheduled — log and continue.
|
|
613
|
+
Logger.w(
|
|
614
|
+
TAG,
|
|
615
|
+
"Failed to reschedule entity "
|
|
616
|
+
+ workDataEntity.getId()
|
|
617
|
+
+ ": "
|
|
618
|
+
+ t.getMessage());
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Awaits all per-entity update futures before finishing the boot
|
|
623
|
+
// receiver, bounded by the 8s ANR safety timeout. Any not-yet-
|
|
624
|
+
// persisted next-fire anchors left behind on timeout will catch up
|
|
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.
|
|
629
|
+
ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
|
|
630
|
+
finishReceiverWhenDone(combined, pendingResult, releaseLock, "rescheduleNotifications");
|
|
340
631
|
}
|
|
341
632
|
|
|
342
633
|
@Override
|
|
343
|
-
public void onFailure(Throwable t) {
|
|
634
|
+
public void onFailure(@NonNull Throwable t) {
|
|
344
635
|
Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
|
|
636
|
+
releaseLock.run();
|
|
345
637
|
if (pendingResult != null) {
|
|
346
638
|
pendingResult.finish();
|
|
347
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
|
}
|