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.
Files changed (27) hide show
  1. package/README.md +200 -5
  2. package/android/build.gradle +1 -0
  3. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  4. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
  5. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  7. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  8. package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
  9. package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
  10. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +385 -93
  11. package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
  12. package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
  13. package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
  14. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
  15. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  16. package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
  17. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
  18. package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
  19. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
  20. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
  21. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  22. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
  23. package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +1 -1
  27. package/src/version.ts +1 -1
@@ -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>
@@ -5,21 +5,32 @@ import static android.app.AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_ST
5
5
  import android.content.BroadcastReceiver;
6
6
  import android.content.Context;
7
7
  import android.content.Intent;
8
- import android.util.Log;
9
8
 
10
9
  public class AlarmPermissionBroadcastReceiver extends BroadcastReceiver {
10
+ private static final String TAG = "AlarmPermissionReceiver";
11
+
11
12
  @Override
12
13
  public void onReceive(Context context, Intent intent) {
14
+ if (!ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED.equals(intent.getAction())) {
15
+ return;
16
+ }
13
17
 
14
- if (intent.getAction().equals(ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)) {
15
- PendingResult pendingResult = goAsync();
16
- Log.i("AlarmPermissionReceiver", "Received alarm permission state changed event");
17
-
18
+ PendingResult pendingResult = goAsync();
19
+ // See RebootBroadcastReceiver for the rationale behind this guard.
20
+ boolean asyncHandoffSucceeded = false;
21
+ try {
22
+ Logger.i(TAG, "Received alarm permission state changed event");
18
23
  if (ContextHolder.getApplicationContext() == null) {
19
24
  ContextHolder.setApplicationContext(context.getApplicationContext());
20
25
  }
21
-
22
26
  new NotifeeAlarmManager().rescheduleNotifications(pendingResult);
27
+ asyncHandoffSucceeded = true;
28
+ } catch (Throwable t) {
29
+ Logger.e(TAG, "Failed to reschedule notifications after permission change", t);
30
+ } finally {
31
+ if (!asyncHandoffSucceeded && pendingResult != null) {
32
+ pendingResult.finish();
33
+ }
23
34
  }
24
35
  }
25
36
  }
@@ -26,9 +26,11 @@ import android.content.pm.ProviderInfo;
26
26
  import android.database.Cursor;
27
27
  import android.net.Uri;
28
28
  import android.os.Trace;
29
+ import android.provider.Settings;
29
30
  import androidx.annotation.CallSuper;
30
31
  import androidx.annotation.NonNull;
31
32
  import androidx.annotation.Nullable;
33
+ import androidx.annotation.VisibleForTesting;
32
34
  import java.util.concurrent.ExecutorService;
33
35
  import java.util.concurrent.Executors;
34
36
 
@@ -64,8 +66,15 @@ public class InitProvider extends ContentProvider {
64
66
  }
65
67
 
66
68
  Context appContext = ContextHolder.getApplicationContext();
67
- if (appContext != null && isWarmupEnabled(appContext)) {
68
- dispatchWarmup(appContext);
69
+ if (appContext != null) {
70
+ if (isWarmupEnabled(appContext)) {
71
+ dispatchWarmup(appContext);
72
+ }
73
+ // Fix for upstream invertase/notifee#734: recover scheduled alarms on every app init
74
+ // when a reboot has occurred since the last run. Runs unconditionally (not gated by
75
+ // the warmup metadata flag) because this is recovery, not optimisation. Always on a
76
+ // background thread to keep app startup clean.
77
+ dispatchBootCheck(appContext);
69
78
  }
70
79
  } finally {
71
80
  Trace.endSection();
@@ -100,6 +109,104 @@ public class InitProvider extends ContentProvider {
100
109
  executor.shutdown();
101
110
  }
102
111
 
112
+ /**
113
+ * Dispatches the BOOT_COUNT cold-start recovery check on a background thread. Mirrors {@link
114
+ * #dispatchWarmup} so that app startup stays free of I/O. Fix for upstream invertase/notifee#734.
115
+ */
116
+ private static void dispatchBootCheck(final Context context) {
117
+ ExecutorService executor =
118
+ Executors.newSingleThreadExecutor(
119
+ r -> {
120
+ Thread t = new Thread(r, "notifee-boot-check");
121
+ t.setDaemon(true);
122
+ t.setPriority(Thread.MIN_PRIORITY);
123
+ return t;
124
+ });
125
+
126
+ executor.submit(() -> runBootCheck(context));
127
+ executor.shutdown();
128
+ }
129
+
130
+ /**
131
+ * Reads {@code Settings.Global.BOOT_COUNT} and compares it against the last-known value stored in
132
+ * {@link Preferences}. If a reboot has occurred since the last app run — or if BOOT_COUNT cannot
133
+ * be read (custom ROMs, emulators, exotic vendors) — invokes {@link
134
+ * NotifeeAlarmManager#rescheduleNotifications(android.content.BroadcastReceiver.PendingResult)}
135
+ * to recover any AlarmManager triggers that may not have been re-armed by {@code
136
+ * RebootBroadcastReceiver} on OEM devices that suppress {@code BOOT_COMPLETED}.
137
+ *
138
+ * <p>Package-private for direct invocation from Robolectric unit tests.
139
+ */
140
+ static void runBootCheck(Context context) {
141
+ try {
142
+ int currentBootCount = readBootCount(context);
143
+ int lastKnownBootCount =
144
+ Preferences.getSharedInstance().getIntValue(Preferences.LAST_KNOWN_BOOT_COUNT_KEY, -1);
145
+
146
+ boolean shouldReschedule = shouldRescheduleAfterBoot(currentBootCount, lastKnownBootCount);
147
+
148
+ if (currentBootCount == -1 && lastKnownBootCount == -1) {
149
+ Logger.i(TAG, "BOOT_COUNT unavailable on first run; running conservative reschedule");
150
+ } else if (currentBootCount == -1) {
151
+ Logger.i(TAG, "BOOT_COUNT unavailable; running conservative reschedule to be safe");
152
+ } else if (lastKnownBootCount == -1) {
153
+ Logger.i(TAG, "First run: recording BOOT_COUNT baseline " + currentBootCount);
154
+ } else if (currentBootCount != lastKnownBootCount) {
155
+ Logger.i(
156
+ TAG,
157
+ "Boot detected since last run ("
158
+ + lastKnownBootCount
159
+ + " -> "
160
+ + currentBootCount
161
+ + "), rescheduling");
162
+ }
163
+
164
+ // Only persist real values. Writing -1 would erase a prior real baseline
165
+ // and cause every subsequent init to run a conservative reschedule.
166
+ if (currentBootCount != -1) {
167
+ Preferences.getSharedInstance()
168
+ .setIntValue(Preferences.LAST_KNOWN_BOOT_COUNT_KEY, currentBootCount);
169
+ }
170
+
171
+ if (shouldReschedule) {
172
+ new NotifeeAlarmManager().rescheduleNotifications(null);
173
+ }
174
+ } catch (Throwable t) {
175
+ Logger.e(TAG, "Cold-start reschedule check failed", t);
176
+ }
177
+ }
178
+
179
+ private static int readBootCount(Context context) {
180
+ try {
181
+ return Settings.Global.getInt(context.getContentResolver(), Settings.Global.BOOT_COUNT, -1);
182
+ } catch (Throwable t) {
183
+ Logger.w(TAG, "Failed to read Settings.Global.BOOT_COUNT", t);
184
+ return -1;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Pure decision function for BOOT_COUNT-based cold-start recovery. Returns {@code true} when the
190
+ * current init should trigger a reschedule pass, {@code false} otherwise.
191
+ *
192
+ * <ul>
193
+ * <li>{@code currentBootCount == -1} → BOOT_COUNT unavailable; reschedule conservatively.
194
+ * <li>{@code lastKnownBootCount == -1} → first run; record baseline without rescheduling.
195
+ * <li>{@code currentBootCount != lastKnownBootCount} → reboot detected; reschedule.
196
+ * <li>Otherwise → same boot as last run; no-op.
197
+ * </ul>
198
+ */
199
+ @VisibleForTesting
200
+ static boolean shouldRescheduleAfterBoot(int currentBootCount, int lastKnownBootCount) {
201
+ if (currentBootCount == -1) {
202
+ return true;
203
+ }
204
+ if (lastKnownBootCount == -1) {
205
+ return false;
206
+ }
207
+ return currentBootCount != lastKnownBootCount;
208
+ }
209
+
103
210
  @Nullable
104
211
  @Override
105
212
  public Cursor query(