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.
- package/README.md +10 -4
- 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 +204 -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/NotifeeAlarmManager.java +160 -79
- package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
- package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
- package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -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
package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java
ADDED
|
@@ -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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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();
|