react-native-notify-kit 9.4.0 → 9.5.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.
@@ -0,0 +1,221 @@
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
+ * NOT RUN IN CI.
15
+ *
16
+ * This test hits a real Room in-memory database and must be executed on a connected device or
17
+ * emulator via:
18
+ *
19
+ * cd apps/smoke/android
20
+ * ./gradlew :react-native-notify-kit:connectedDebugAndroidTest
21
+ *
22
+ * before merging any change that touches the WorkDataRepository layer or the Room schema. A
23
+ * follow-up task tracks wiring this into CI with reactivecircus/android-emulator-runner — see the
24
+ * "Wire androidTest into CI" issue linked from the #549 fix PR description.
25
+ *
26
+ * The scenarios below are the instrumented analogues of the repro-549-findings.md harness:
27
+ * post-cancel consistency (Scenario B), post-create persistence (Scenario C), and concurrent
28
+ * stress (Scenario D). They run 100 iterations each and must observe zero inconsistencies after
29
+ * the #549 fix; any non-zero count is a regression.
30
+ */
31
+
32
+ import static org.junit.Assert.assertEquals;
33
+ import static org.junit.Assert.assertFalse;
34
+ import static org.junit.Assert.assertTrue;
35
+ import static org.junit.Assert.fail;
36
+
37
+ import androidx.room.Room;
38
+ import androidx.test.ext.junit.runners.AndroidJUnit4;
39
+ import androidx.test.platform.app.InstrumentationRegistry;
40
+ import com.google.common.util.concurrent.ListenableFuture;
41
+ import com.google.common.util.concurrent.ListeningExecutorService;
42
+ import com.google.common.util.concurrent.MoreExecutors;
43
+ import java.util.ArrayList;
44
+ import java.util.List;
45
+ import java.util.concurrent.ExecutionException;
46
+ import java.util.concurrent.ExecutorService;
47
+ import java.util.concurrent.Executors;
48
+ import java.util.concurrent.TimeUnit;
49
+ import org.junit.After;
50
+ import org.junit.Before;
51
+ import org.junit.Test;
52
+ import org.junit.runner.RunWith;
53
+
54
+ @RunWith(AndroidJUnit4.class)
55
+ public class WorkDataRepositoryRaceTest {
56
+
57
+ private NotifeeCoreDatabase db;
58
+ private WorkDataRepository repo;
59
+ private ListeningExecutorService executor;
60
+
61
+ @Before
62
+ public void setUp() {
63
+ db =
64
+ Room.inMemoryDatabaseBuilder(
65
+ InstrumentationRegistry.getInstrumentation().getTargetContext(),
66
+ NotifeeCoreDatabase.class)
67
+ .allowMainThreadQueries()
68
+ .build();
69
+ // Shared cached thread pool matches the production executor's concurrency profile.
70
+ executor = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
71
+ repo = new WorkDataRepository(db.workDao(), executor);
72
+ }
73
+
74
+ @After
75
+ public void tearDown() {
76
+ db.close();
77
+ executor.shutdownNow();
78
+ }
79
+
80
+ // ------- Scenario B analogue: post-cancel consistency -------
81
+
82
+ @Test
83
+ public void deleteAll_thenGetAll_isEmpty_100iterations()
84
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
85
+ for (int i = 0; i < 100; i++) {
86
+ // Seed 20 rows
87
+ List<ListenableFuture<Void>> seedFutures = new ArrayList<>(20);
88
+ for (int j = 0; j < 20; j++) {
89
+ seedFutures.add(repo.insert(entity("b-" + i + "-" + j)));
90
+ }
91
+ for (ListenableFuture<Void> f : seedFutures) {
92
+ f.get(2, TimeUnit.SECONDS);
93
+ }
94
+
95
+ // Assert seeded
96
+ assertEquals(20, repo.getAll().get(2, TimeUnit.SECONDS).size());
97
+
98
+ // Cancel all, then immediately read — must be empty.
99
+ repo.deleteAll().get(2, TimeUnit.SECONDS);
100
+ int immediateCount = repo.getAll().get(2, TimeUnit.SECONDS).size();
101
+ assertEquals(
102
+ "iteration " + i + ": deleteAll must complete before its future resolves",
103
+ 0,
104
+ immediateCount);
105
+ }
106
+ }
107
+
108
+ // ------- Scenario C analogue: post-create persistence -------
109
+
110
+ @Test
111
+ public void insert_thenGet_isVisible_100iterations()
112
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
113
+ for (int i = 0; i < 100; i++) {
114
+ repo.deleteAll().get(2, TimeUnit.SECONDS);
115
+ String id = "c-" + i;
116
+ repo.insert(entity(id)).get(2, TimeUnit.SECONDS);
117
+
118
+ WorkDataEntity row = repo.getWorkDataById(id).get(2, TimeUnit.SECONDS);
119
+ assertTrue(
120
+ "iteration " + i + ": insert future resolved but row is not in Room yet", row != null);
121
+ assertEquals(id, row.getId());
122
+ }
123
+ }
124
+
125
+ // ------- Delete-by-id consistency -------
126
+
127
+ @Test
128
+ public void deleteById_thenGet_isNull_100iterations()
129
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
130
+ for (int i = 0; i < 100; i++) {
131
+ String id = "d-" + i;
132
+ repo.insert(entity(id)).get(2, TimeUnit.SECONDS);
133
+ repo.deleteById(id).get(2, TimeUnit.SECONDS);
134
+ WorkDataEntity row = repo.getWorkDataById(id).get(2, TimeUnit.SECONDS);
135
+ assertTrue("iteration " + i + ": deleteById resolved but row still present", row == null);
136
+ }
137
+ }
138
+
139
+ // ------- Update visibility -------
140
+
141
+ @Test
142
+ public void update_thenGet_reflectsNewTrigger_100iterations()
143
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
144
+ for (int i = 0; i < 100; i++) {
145
+ String id = "u-" + i;
146
+ byte[] notification = new byte[] {1, 2, 3};
147
+ byte[] initialTrigger = new byte[] {(byte) i};
148
+ repo.insert(new WorkDataEntity(id, notification, initialTrigger, false))
149
+ .get(2, TimeUnit.SECONDS);
150
+
151
+ byte[] updatedTrigger = new byte[] {(byte) (i + 100)};
152
+ repo.update(new WorkDataEntity(id, notification, updatedTrigger, true))
153
+ .get(2, TimeUnit.SECONDS);
154
+
155
+ WorkDataEntity row = repo.getWorkDataById(id).get(2, TimeUnit.SECONDS);
156
+ assertTrue(row != null);
157
+ assertEquals(
158
+ "iteration " + i + ": updated trigger byte must be visible immediately",
159
+ (byte) (i + 100),
160
+ row.getTrigger()[0]);
161
+ assertTrue(row.getWithAlarmManager());
162
+ }
163
+ }
164
+
165
+ // ------- Scenario D analogue: concurrent stress -------
166
+
167
+ /**
168
+ * Fire 20 concurrent insert and 20 concurrent deleteAll operations across a fixed thread pool and
169
+ * verify the system reaches a deterministic final state after all futures complete. This does NOT
170
+ * assert a specific final count — ordering between concurrent creates and cancels is
171
+ * implementation-defined — but it does assert that every future completes successfully and the DB
172
+ * is readable at the end.
173
+ */
174
+ @Test
175
+ public void concurrentInsertAndDelete_allFuturesComplete()
176
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
177
+ ExecutorService testPool = Executors.newFixedThreadPool(8);
178
+ try {
179
+ List<ListenableFuture<Void>> futures = new ArrayList<>();
180
+ for (int i = 0; i < 20; i++) {
181
+ futures.add(repo.insert(entity("s-" + i)));
182
+ if (i % 3 == 0) {
183
+ futures.add(repo.deleteAll());
184
+ }
185
+ }
186
+ for (ListenableFuture<Void> f : futures) {
187
+ f.get(5, TimeUnit.SECONDS);
188
+ }
189
+ // Final read must succeed — we only assert the DB is readable, not the count.
190
+ int finalCount = repo.getAll().get(2, TimeUnit.SECONDS).size();
191
+ assertTrue("final count must be non-negative: " + finalCount, finalCount >= 0);
192
+ } finally {
193
+ testPool.shutdownNow();
194
+ }
195
+ }
196
+
197
+ // ------- DAO exceptions surface via ExecutionException -------
198
+
199
+ @Test
200
+ public void insertDuplicatePrimaryKey_failsFutureWithExecutionException()
201
+ throws ExecutionException, InterruptedException, java.util.concurrent.TimeoutException {
202
+ repo.insert(entity("dup")).get(2, TimeUnit.SECONDS);
203
+ try {
204
+ // Room throws SQLiteConstraintException on duplicate primary key when using OnConflict.ABORT
205
+ // (the default). If the DAO uses REPLACE this test will pass trivially; adjust when the
206
+ // DAO's @Insert strategy changes.
207
+ repo.insert(entity("dup")).get(2, TimeUnit.SECONDS);
208
+ // If we reach here the DAO uses REPLACE or similar — mark the test as passing but log.
209
+ } catch (ExecutionException e) {
210
+ assertFalse("ExecutionException must wrap a real cause, not null", e.getCause() == null);
211
+ } catch (Throwable unexpected) {
212
+ fail("Expected ExecutionException or success, got: " + unexpected);
213
+ }
214
+ }
215
+
216
+ // ------- helpers -------
217
+
218
+ private static WorkDataEntity entity(String id) {
219
+ return new WorkDataEntity(id, new byte[] {0}, new byte[] {0}, false);
220
+ }
221
+ }
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ Tiny transparent vector drawable used as a smallIcon placeholder in
4
+ DoScheduledWorkOrderingTest so NotificationManagerCompat.notify() does not
5
+ reject the test notification for missing icon. Only referenced by the
6
+ androidTest APK — never shipped to consumers.
7
+ -->
8
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
9
+ android:width="24dp"
10
+ android:height="24dp"
11
+ android:viewportWidth="24"
12
+ android:viewportHeight="24">
13
+ <path android:fillColor="#00000000" android:pathData="M0,0h24v24H0z" />
14
+ </vector>
@@ -26,6 +26,7 @@ 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;
30
31
  import androidx.core.app.AlarmManagerCompat;
31
32
  import app.notifee.core.database.WorkDataEntity;
@@ -40,10 +41,14 @@ import com.google.common.util.concurrent.Futures;
40
41
  import com.google.common.util.concurrent.ListenableFuture;
41
42
  import com.google.common.util.concurrent.ListeningExecutorService;
42
43
  import com.google.common.util.concurrent.MoreExecutors;
44
+ import java.util.ArrayList;
43
45
  import java.util.Arrays;
44
46
  import java.util.List;
45
47
  import java.util.concurrent.ExecutorService;
46
48
  import java.util.concurrent.Executors;
49
+ import java.util.concurrent.ScheduledExecutorService;
50
+ import java.util.concurrent.TimeUnit;
51
+ import java.util.concurrent.TimeoutException;
47
52
 
48
53
  class NotifeeAlarmManager {
49
54
  private static final String TAG = "NotifeeAlarmManager";
@@ -52,6 +57,59 @@ class NotifeeAlarmManager {
52
57
  private static final ListeningExecutorService alarmManagerListeningExecutor =
53
58
  MoreExecutors.listeningDecorator(alarmManagerExecutor);
54
59
 
60
+ // Scheduler used only as the timeout clock for Futures.withTimeout on Room
61
+ // writes happening inside BroadcastReceiver.goAsync() scopes. Broadcasts have
62
+ // ~10s before Android kills the process, so we cap the Room wait at 8s and
63
+ // call pendingResult.finish() anyway on timeout to avoid ANRs.
64
+ private static final ScheduledExecutorService TIMEOUT_SCHEDULER =
65
+ Executors.newSingleThreadScheduledExecutor();
66
+ private static final long RECEIVER_WRITE_TIMEOUT_SECONDS = 8;
67
+
68
+ /**
69
+ * Wraps {@code future} in a {@link Futures#withTimeout} safety net and arranges for {@code
70
+ * pendingResult.finish()} to be called exactly once — on success, on failure, or on timeout. Use
71
+ * from a {@link BroadcastReceiver#goAsync} scope where the receiver must tell Android "I'm done"
72
+ * within ~10s or the process is killed. Timeouts are logged at {@code WARN} with the supplied
73
+ * {@code logContext}; real failures at {@code ERROR}.
74
+ */
75
+ private static void finishReceiverWhenDone(
76
+ @NonNull ListenableFuture<?> future,
77
+ @Nullable BroadcastReceiver.PendingResult pendingResult,
78
+ @NonNull String logContext) {
79
+ ListenableFuture<?> bounded =
80
+ Futures.withTimeout(
81
+ future, RECEIVER_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS, TIMEOUT_SCHEDULER);
82
+ Futures.addCallback(
83
+ bounded,
84
+ new FutureCallback<Object>() {
85
+ @Override
86
+ public void onSuccess(Object result) {
87
+ if (pendingResult != null) {
88
+ pendingResult.finish();
89
+ }
90
+ }
91
+
92
+ @Override
93
+ public void onFailure(@NonNull Throwable t) {
94
+ if (t instanceof TimeoutException) {
95
+ Logger.w(
96
+ TAG,
97
+ "Room write for "
98
+ + logContext
99
+ + " did not complete within "
100
+ + RECEIVER_WRITE_TIMEOUT_SECONDS
101
+ + "s; finishing BroadcastReceiver anyway to avoid ANR");
102
+ } else {
103
+ Logger.e(TAG, "Failure in " + logContext, new Exception(t));
104
+ }
105
+ if (pendingResult != null) {
106
+ pendingResult.finish();
107
+ }
108
+ }
109
+ },
110
+ alarmManagerListeningExecutor);
111
+ }
112
+
55
113
  static void displayScheduledNotification(
56
114
  Bundle alarmManagerNotification, @Nullable BroadcastReceiver.PendingResult pendingResult) {
57
115
  if (alarmManagerNotification == null) {
@@ -71,72 +129,60 @@ class NotifeeAlarmManager {
71
129
 
72
130
  WorkDataRepository workDataRepository = new WorkDataRepository(getApplicationContext());
73
131
 
74
- ListenableFuture<?> displayFuture =
75
- new ExtendedListenableFuture<>(workDataRepository.getWorkDataById(id))
76
- .continueWith(
77
- workDataEntity -> {
78
- Bundle notificationBundle;
79
-
80
- Bundle triggerBundle;
81
-
82
- if (workDataEntity == null
83
- || workDataEntity.getNotification() == null
84
- || workDataEntity.getTrigger() == null) {
85
- // check if notification bundle is stored with Work Manager
86
- Logger.w(
87
- TAG,
88
- "Attempted to handle doScheduledWork but no notification data was found.");
89
- return Futures.immediateFuture(null);
90
- } else {
91
- triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
92
- notificationBundle =
93
- ObjectUtils.bytesToBundle(workDataEntity.getNotification());
94
- }
95
-
96
- NotificationModel notificationModel =
97
- NotificationModel.fromBundle(notificationBundle);
98
-
99
- return new ExtendedListenableFuture<>(
100
- NotificationManager.displayNotification(notificationModel, triggerBundle))
101
- .continueWith(
102
- voidDisplayedNotification -> {
103
- if (triggerBundle.containsKey("repeatFrequency")
104
- && ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
105
- TimestampTriggerModel trigger =
106
- TimestampTriggerModel.fromBundle(triggerBundle);
107
- // scheduleTimestampTriggerNotification() calls setNextTimestamp()
108
- // internally, so we must NOT call it here to avoid double-advancing
109
- scheduleTimestampTriggerNotification(notificationModel, trigger);
110
- WorkDataRepository.getInstance(getApplicationContext())
111
- .update(
112
- new WorkDataEntity(
113
- id,
114
- workDataEntity.getNotification(),
115
- ObjectUtils.bundleToBytes(trigger.toBundle()),
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();
132
+ // Chain: read → display → persist (update for repeat / delete for one-shot).
133
+ // The final Room write is awaited before pendingResult.finish() so the
134
+ // BroadcastReceiver only tells Android "I'm done" once the next-fire anchor
135
+ // (or the deletion) has actually landed in Room. Without this, process death
136
+ // between finish() and the enqueued write could lose the updated timestamp
137
+ // and cause the same alarm to fire again on the next reboot. See #549 audit
138
+ // callers #6 and #7.
139
+ ListenableFuture<Void> displayAndPersistFuture =
140
+ Futures.transformAsync(
141
+ workDataRepository.getWorkDataById(id),
142
+ workDataEntity -> {
143
+ if (workDataEntity == null
144
+ || workDataEntity.getNotification() == null
145
+ || workDataEntity.getTrigger() == null) {
146
+ Logger.w(
147
+ TAG, "Attempted to handle doScheduledWork but no notification data was found.");
148
+ return Futures.immediateFuture(null);
149
+ }
150
+
151
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(workDataEntity.getTrigger());
152
+ Bundle notificationBundle =
153
+ ObjectUtils.bytesToBundle(workDataEntity.getNotification());
154
+ NotificationModel notificationModel =
155
+ NotificationModel.fromBundle(notificationBundle);
156
+
157
+ return Futures.transformAsync(
158
+ NotificationManager.displayNotification(notificationModel, triggerBundle),
159
+ voidDisplayedNotification -> {
160
+ if (triggerBundle.containsKey("repeatFrequency")
161
+ && ObjectUtils.getInt(triggerBundle.get("repeatFrequency")) != -1) {
162
+ TimestampTriggerModel trigger =
163
+ TimestampTriggerModel.fromBundle(triggerBundle);
164
+ // scheduleTimestampTriggerNotification() calls setNextTimestamp()
165
+ // internally, so we must NOT call it here to avoid double-advancing
166
+ scheduleTimestampTriggerNotification(notificationModel, trigger);
167
+ return WorkDataRepository.getInstance(getApplicationContext())
168
+ .update(
169
+ new WorkDataEntity(
170
+ id,
171
+ workDataEntity.getNotification(),
172
+ ObjectUtils.bundleToBytes(trigger.toBundle()),
173
+ true));
136
174
  }
137
- }
138
- },
139
- alarmManagerExecutor);
175
+ // not repeating, delete database entry if work is a one-time request
176
+ return WorkDataRepository.getInstance(getApplicationContext()).deleteById(id);
177
+ },
178
+ alarmManagerExecutor);
179
+ },
180
+ alarmManagerExecutor);
181
+
182
+ // Awaits the Room write before pendingResult.finish(), bounded by the 8s
183
+ // ANR safety timeout. Handles success, failure, and timeout uniformly.
184
+ finishReceiverWhenDone(
185
+ displayAndPersistFuture, pendingResult, "displayScheduledNotification[" + id + "]");
140
186
  }
141
187
 
142
188
  public static PendingIntent getAlarmManagerIntentForNotification(String notificationId) {
@@ -283,10 +329,18 @@ class NotifeeAlarmManager {
283
329
  alarmManagerListeningExecutor);
284
330
  }
285
331
 
286
- /* On reboot, reschedule trigger notifications created via alarm manager */
287
- void rescheduleNotification(WorkDataEntity workDataEntity) {
332
+ /**
333
+ * On reboot, reschedule one trigger notification created via alarm manager.
334
+ *
335
+ * <p>Returns a future that completes when Room has persisted the updated next-fire anchor. The
336
+ * caller MUST await this future before calling {@code pendingResult.finish()} — otherwise Android
337
+ * may kill the boot receiver's process before Room has drained, and the next reboot will
338
+ * reschedule from the stale anchor. This bug is NOT in upstream invertase/notifee#549 and was
339
+ * surfaced only by the pre-fix-549-audit.md read-only caller audit (Caller #8).
340
+ */
341
+ ListenableFuture<Void> rescheduleNotification(WorkDataEntity workDataEntity) {
288
342
  if (workDataEntity.getNotification() == null || workDataEntity.getTrigger() == null) {
289
- return;
343
+ return Futures.immediateFuture(null);
290
344
  }
291
345
 
292
346
  byte[] notificationBytes = workDataEntity.getNotification();
@@ -302,22 +356,23 @@ class NotifeeAlarmManager {
302
356
  case 0:
303
357
  TimestampTriggerModel trigger = TimestampTriggerModel.fromBundle(triggerBundle);
304
358
  if (!trigger.getWithAlarmManager()) {
305
- return;
359
+ return Futures.immediateFuture(null);
306
360
  }
307
361
 
308
362
  scheduleTimestampTriggerNotification(notificationModel, trigger);
309
- // Persist updated timestamp so next reboot starts from the correct anchor
310
- WorkDataRepository.getInstance(getApplicationContext())
363
+ // Persist updated timestamp so next reboot starts from the correct anchor.
364
+ return WorkDataRepository.getInstance(getApplicationContext())
311
365
  .update(
312
366
  new WorkDataEntity(
313
367
  workDataEntity.getId(),
314
368
  workDataEntity.getNotification(),
315
369
  ObjectUtils.bundleToBytes(trigger.toBundle()),
316
370
  workDataEntity.getWithAlarmManager()));
317
- break;
318
371
  case 1:
319
372
  // TODO: support interval triggers with alarm manager
320
- break;
373
+ return Futures.immediateFuture(null);
374
+ default:
375
+ return Futures.immediateFuture(null);
321
376
  }
322
377
  }
323
378
 
@@ -328,19 +383,45 @@ class NotifeeAlarmManager {
328
383
  new FutureCallback<List<WorkDataEntity>>() {
329
384
  @Override
330
385
  public void onSuccess(List<WorkDataEntity> workDataEntities) {
331
- try {
332
- for (WorkDataEntity workDataEntity : workDataEntities) {
333
- rescheduleNotification(workDataEntity);
334
- }
335
- } finally {
386
+ Logger.d(
387
+ TAG,
388
+ "Reschedule starting for "
389
+ + (workDataEntities != null ? workDataEntities.size() : 0)
390
+ + " recurring alarms");
391
+
392
+ if (workDataEntities == null || workDataEntities.isEmpty()) {
336
393
  if (pendingResult != null) {
337
394
  pendingResult.finish();
338
395
  }
396
+ return;
339
397
  }
398
+
399
+ List<ListenableFuture<Void>> updateFutures = new ArrayList<>(workDataEntities.size());
400
+ for (WorkDataEntity workDataEntity : workDataEntities) {
401
+ try {
402
+ updateFutures.add(rescheduleNotification(workDataEntity));
403
+ } catch (Throwable t) {
404
+ // A single bad entity must not prevent the rest of the batch
405
+ // from being rescheduled — log and continue.
406
+ Logger.w(
407
+ TAG,
408
+ "Failed to reschedule entity "
409
+ + workDataEntity.getId()
410
+ + ": "
411
+ + t.getMessage());
412
+ }
413
+ }
414
+
415
+ // Awaits all per-entity update futures before finishing the boot
416
+ // receiver, bounded by the 8s ANR safety timeout. Any not-yet-
417
+ // persisted next-fire anchors left behind on timeout will catch up
418
+ // on the next alarm fire.
419
+ ListenableFuture<List<Void>> combined = Futures.allAsList(updateFutures);
420
+ finishReceiverWhenDone(combined, pendingResult, "rescheduleNotifications");
340
421
  }
341
422
 
342
423
  @Override
343
- public void onFailure(Throwable t) {
424
+ public void onFailure(@NonNull Throwable t) {
344
425
  Logger.e(TAG, "Failed to reschedule notifications", new Exception(t));
345
426
  if (pendingResult != null) {
346
427
  pendingResult.finish();