react-native-notify-kit 9.5.0 → 9.7.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 (25) hide show
  1. package/README.md +204 -2
  2. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +378 -13
  3. package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
  4. package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
  5. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +228 -17
  6. package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
  7. package/android/src/main/java/app/notifee/core/NotificationManager.java +98 -0
  8. package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
  9. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
  10. package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
  11. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
  12. package/android/src/test/java/app/notifee/core/GetDisplayedNotificationsDataTest.java +242 -0
  13. package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
  14. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
  15. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
  16. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
  17. package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
  18. package/dist/types/Notification.d.ts +35 -4
  19. package/dist/types/Notification.js.map +1 -1
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/jest-mock.js +1 -0
  23. package/package.json +1 -1
  24. package/src/types/Notification.ts +35 -4
  25. package/src/version.ts +1 -1
@@ -1,11 +1,26 @@
1
1
  package app.notifee.core;
2
2
 
3
3
  import static org.junit.Assert.assertEquals;
4
+ import static org.junit.Assert.assertNotNull;
5
+ import static org.junit.Assert.assertNull;
4
6
  import static org.junit.Assert.assertTrue;
5
7
  import static org.junit.Assert.fail;
6
8
 
9
+ import android.app.Notification;
10
+ import android.app.NotificationChannel;
11
+ import android.app.NotificationManager;
7
12
  import android.app.Service;
13
+ import android.content.Context;
8
14
  import android.content.Intent;
15
+ import android.content.pm.ServiceInfo;
16
+ import android.os.Bundle;
17
+ import androidx.core.app.NotificationCompat;
18
+ import app.notifee.core.event.NotificationEvent;
19
+ import java.lang.reflect.Field;
20
+ import java.util.ArrayList;
21
+ import java.util.List;
22
+ import org.greenrobot.eventbus.Subscribe;
23
+ import org.greenrobot.eventbus.ThreadMode;
9
24
  import org.junit.After;
10
25
  import org.junit.Before;
11
26
  import org.junit.Test;
@@ -26,12 +41,15 @@ public class ForegroundServiceTest {
26
41
  }
27
42
 
28
43
  @After
29
- public void tearDown() {
30
- // Reset all accessible static fields to prevent cross-test pollution.
31
- // mCurrentNotificationBundle, mCurrentNotification, mCurrentHashCode are private static
32
- // and can only be reset via onStartCommand (the STOP path clears them).
44
+ public void tearDown() throws Exception {
45
+ // Reset public and private static fields to prevent cross-test pollution. The three private
46
+ // statics are cleared via reflection here so that tests which seed them directly (to exercise
47
+ // onTimeout without running the full START path) cannot leak state into neighbouring tests.
33
48
  ForegroundService.mCurrentNotificationId = null;
34
49
  ForegroundService.mCurrentForegroundServiceType = -1;
50
+ setPrivateStatic("mCurrentNotificationBundle", null);
51
+ setPrivateStatic("mCurrentNotification", null);
52
+ setPrivateStatic("mCurrentHashCode", 0);
35
53
  }
36
54
 
37
55
  /**
@@ -179,4 +197,233 @@ public class ForegroundServiceTest {
179
197
  int result = service.onStartCommand(stopIntent, 0, 2);
180
198
  assertEquals(Service.START_STICKY_COMPATIBILITY, result);
181
199
  }
200
+
201
+ // ──────────────────────────────────────────────────────────────────────────
202
+ // START path and onTimeout event emission (regression guards for 9.1.13)
203
+ // ──────────────────────────────────────────────────────────────────────────
204
+
205
+ private static final String TEST_CHANNEL_ID = "fgs-test-channel";
206
+
207
+ /**
208
+ * START happy path: a valid intent with a notification payload must drive the service through
209
+ * {@code startForeground()} and leave {@code mCurrentNotificationId} populated so a subsequent
210
+ * STOP/onTimeout can reference it. Runs on SDK 33 to avoid the API 34+ manifest-type check, which
211
+ * would require a test-specific {@code foregroundServiceType} declaration.
212
+ */
213
+ @Test
214
+ @Config(sdk = 33)
215
+ public void onStartCommand_startIntent_setsCurrentNotificationIdAndCallsStartForeground()
216
+ throws Exception {
217
+ createChannel();
218
+ ServiceController<ForegroundService> controller =
219
+ Robolectric.buildService(ForegroundService.class);
220
+ ForegroundService service = controller.create().get();
221
+
222
+ String id = "fgs-start-happy";
223
+ int result =
224
+ service.onStartCommand(
225
+ buildStartIntent(id, id.hashCode()), /* flags= */ 0, /* startId= */ 1);
226
+
227
+ assertEquals(Service.START_NOT_STICKY, result);
228
+ assertEquals(id, ForegroundService.mCurrentNotificationId);
229
+ // Robolectric's ShadowService records the most recent Notification passed to
230
+ // startForeground(); a non-null result proves the 3-arg startForeground() overload on the
231
+ // API 33 branch of onStartCommand was exercised and Android's contract was satisfied.
232
+ Notification posted = org.robolectric.Shadows.shadowOf(service).getLastForegroundNotification();
233
+ assertNotNull("startForeground() must have been called during the START path", posted);
234
+ // The private static mCurrentHashCode tracks the caller-supplied hash; verifying it via
235
+ // reflection proves the START branch fully ran (not just the early-return path).
236
+ Field hashField = ForegroundService.class.getDeclaredField("mCurrentHashCode");
237
+ hashField.setAccessible(true);
238
+ assertEquals(id.hashCode(), hashField.getInt(null));
239
+ }
240
+
241
+ /**
242
+ * Regression guard for the 9.1.13 {@code onTimeout(int)} fix (upstream invertase/notifee#703). On
243
+ * API 34, Android's single-argument {@code onTimeout} fires when a {@code shortService} FGS
244
+ * exceeds its 3-minute budget. The handler must:
245
+ *
246
+ * <ol>
247
+ * <li>emit a {@link NotificationEvent} with type {@link NotificationEvent#TYPE_FG_TIMEOUT},
248
+ * <li>carry the originating notification model so JS can correlate the event,
249
+ * <li>populate {@code startId} and {@code fgsType} extras (the latter is {@code -1} as a
250
+ * sentinel on the single-argument variant), and
251
+ * <li>reset the service's static tracking state.
252
+ * </ol>
253
+ */
254
+ @Test
255
+ @Config(sdk = 34)
256
+ public void onTimeout_api34_emitsFgTimeoutEventWithStartIdAndSentinelFgsType() throws Exception {
257
+ ServiceController<ForegroundService> controller =
258
+ Robolectric.buildService(ForegroundService.class);
259
+ ForegroundService service = controller.create().get();
260
+
261
+ String id = "fgs-timeout-api34";
262
+ seedActiveForegroundServiceState(id);
263
+
264
+ FgsEventCapture capture = new FgsEventCapture();
265
+ EventBus.register(capture);
266
+ try {
267
+ int startId = 42;
268
+ service.onTimeout(startId);
269
+
270
+ assertEquals(
271
+ "exactly one NotificationEvent should be emitted on timeout", 1, capture.events.size());
272
+ NotificationEvent event = capture.events.get(0);
273
+ assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
274
+ assertNotNull(
275
+ "timeout event must carry the originating notification", event.getNotification());
276
+ assertEquals(id, event.getNotification().getId());
277
+ assertNotNull("timeout event must carry startId/fgsType extras", event.getExtras());
278
+ assertEquals(startId, event.getExtras().getInt("startId"));
279
+ // handleTimeout is called with -1 as the fgsType sentinel from the single-argument overload.
280
+ assertEquals(-1, event.getExtras().getInt("fgsType"));
281
+ } finally {
282
+ EventBus.unregister(capture);
283
+ }
284
+
285
+ assertNull(
286
+ "mCurrentNotificationId must be cleared after onTimeout",
287
+ ForegroundService.mCurrentNotificationId);
288
+ assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
289
+ }
290
+
291
+ /**
292
+ * Regression guard for the API 35+ {@code onTimeout(int, int)} overload, which supersedes the
293
+ * single-argument variant and surfaces the type-specific timeout cause (e.g. {@code
294
+ * FOREGROUND_SERVICE_TYPE_DATA_SYNC}'s new Android 15 cumulative cap). The emitted event must
295
+ * carry the explicit {@code fgsType} value, not the sentinel.
296
+ */
297
+ @Test
298
+ @Config(sdk = 35)
299
+ public void onTimeout_api35_emitsFgTimeoutEventWithExplicitFgsType() throws Exception {
300
+ ServiceController<ForegroundService> controller =
301
+ Robolectric.buildService(ForegroundService.class);
302
+ ForegroundService service = controller.create().get();
303
+
304
+ String id = "fgs-timeout-api35";
305
+ seedActiveForegroundServiceState(id);
306
+
307
+ FgsEventCapture capture = new FgsEventCapture();
308
+ EventBus.register(capture);
309
+ try {
310
+ int startId = 7;
311
+ int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
312
+ service.onTimeout(startId, fgsType);
313
+
314
+ assertEquals(1, capture.events.size());
315
+ NotificationEvent event = capture.events.get(0);
316
+ assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
317
+ assertEquals(id, event.getNotification().getId());
318
+ assertNotNull(event.getExtras());
319
+ assertEquals(startId, event.getExtras().getInt("startId"));
320
+ assertEquals(fgsType, event.getExtras().getInt("fgsType"));
321
+ } finally {
322
+ EventBus.unregister(capture);
323
+ }
324
+
325
+ assertNull(ForegroundService.mCurrentNotificationId);
326
+ assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
327
+ }
328
+
329
+ /**
330
+ * Defensive behaviour: if onTimeout fires on a service instance whose static state has already
331
+ * been cleared (a race between STOP and the Android system delivering a delayed timeout), the
332
+ * handler must not crash and must not post a stray event.
333
+ */
334
+ @Test
335
+ @Config(sdk = 34)
336
+ public void onTimeout_withNoActiveState_doesNotCrashOrEmitEvent() {
337
+ ServiceController<ForegroundService> controller =
338
+ Robolectric.buildService(ForegroundService.class);
339
+ ForegroundService service = controller.create().get();
340
+
341
+ FgsEventCapture capture = new FgsEventCapture();
342
+ EventBus.register(capture);
343
+ try {
344
+ service.onTimeout(/* startId= */ 99);
345
+ assertEquals(0, capture.events.size());
346
+ } finally {
347
+ EventBus.unregister(capture);
348
+ }
349
+ }
350
+
351
+ // ──────────────────────────────────────────────────────────────────────────
352
+ // Helpers
353
+ // ──────────────────────────────────────────────────────────────────────────
354
+
355
+ private static void createChannel() {
356
+ Context context = RuntimeEnvironment.getApplication();
357
+ NotificationManager nm =
358
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
359
+ if (nm != null && nm.getNotificationChannel(TEST_CHANNEL_ID) == null) {
360
+ NotificationChannel channel =
361
+ new NotificationChannel(
362
+ TEST_CHANNEL_ID, "FGS test channel", NotificationManager.IMPORTANCE_LOW);
363
+ nm.createNotificationChannel(channel);
364
+ }
365
+ }
366
+
367
+ private static Bundle buildNotificationBundle(String id) {
368
+ Bundle bundle = new Bundle();
369
+ bundle.putString("id", id);
370
+ bundle.putString("title", "FGS test " + id);
371
+ Bundle androidBundle = new Bundle();
372
+ androidBundle.putString("channelId", TEST_CHANNEL_ID);
373
+ bundle.putBundle("android", androidBundle);
374
+ return bundle;
375
+ }
376
+
377
+ private static Notification buildNotification() {
378
+ Context context = RuntimeEnvironment.getApplication();
379
+ return new NotificationCompat.Builder(context, TEST_CHANNEL_ID)
380
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
381
+ .setContentTitle("FGS test")
382
+ .build();
383
+ }
384
+
385
+ private static Intent buildStartIntent(String id, int hashCode) {
386
+ Intent intent = new Intent();
387
+ intent.setAction(ForegroundService.START_FOREGROUND_SERVICE_ACTION);
388
+ intent.putExtra("hashCode", hashCode);
389
+ intent.putExtra("notification", buildNotification());
390
+ intent.putExtra("notificationBundle", buildNotificationBundle(id));
391
+ return intent;
392
+ }
393
+
394
+ /**
395
+ * Populates the service's private static tracking fields directly, simulating the post-START
396
+ * state that onTimeout expects to observe. Using reflection here (rather than running a full
397
+ * START first) keeps the onTimeout tests SDK-independent — the START path would trip the API 34+
398
+ * manifest-type check, but onTimeout itself is sdk-agnostic because its body only uses pre-API-34
399
+ * primitives.
400
+ */
401
+ private static void seedActiveForegroundServiceState(String id) throws Exception {
402
+ ForegroundService.mCurrentNotificationId = id;
403
+ ForegroundService.mCurrentForegroundServiceType = 1;
404
+ setPrivateStatic("mCurrentNotificationBundle", buildNotificationBundle(id));
405
+ setPrivateStatic("mCurrentNotification", buildNotification());
406
+ setPrivateStatic("mCurrentHashCode", id.hashCode());
407
+ }
408
+
409
+ private static void setPrivateStatic(String name, Object value) throws Exception {
410
+ Field field = ForegroundService.class.getDeclaredField(name);
411
+ field.setAccessible(true);
412
+ field.set(null, value);
413
+ }
414
+
415
+ /**
416
+ * greenrobot EventBus subscriber that records every {@link NotificationEvent} posted during a
417
+ * test. {@link ThreadMode#POSTING} fires the subscriber synchronously on the caller thread, so
418
+ * the test can inspect {@code events} immediately after {@code post()} returns without any looper
419
+ * advancement.
420
+ */
421
+ public static class FgsEventCapture {
422
+ final List<NotificationEvent> events = new ArrayList<>();
423
+
424
+ @Subscribe(threadMode = ThreadMode.POSTING)
425
+ public void onNotificationEvent(NotificationEvent event) {
426
+ events.add(event);
427
+ }
428
+ }
182
429
  }
@@ -0,0 +1,242 @@
1
+ package app.notifee.core;
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
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import static org.junit.Assert.assertEquals;
20
+ import static org.junit.Assert.assertFalse;
21
+ import static org.junit.Assert.assertNotNull;
22
+ import static org.junit.Assert.assertTrue;
23
+
24
+ import android.os.Bundle;
25
+ import java.util.Arrays;
26
+ import java.util.HashSet;
27
+ import java.util.Set;
28
+ import org.junit.Test;
29
+ import org.junit.runner.RunWith;
30
+ import org.robolectric.RobolectricTestRunner;
31
+
32
+ /**
33
+ * Unit tests for {@link NotificationManager#shouldIncludeInData(String)} and {@link
34
+ * NotificationManager#extractDataFromExtras(android.os.Bundle)} — the denylist filter and
35
+ * bundle-to-bundle helper added for upstream invertase/notifee#393.
36
+ *
37
+ * <p>The end-to-end {@code getDisplayedNotifications()} path is intentionally not exercised here
38
+ * because it depends on {@code android.app.NotificationManager.getActiveNotifications()}, which
39
+ * would require a system service mock. Extracting the pure predicate and the extras iteration into
40
+ * helper methods lets these tests run under Robolectric with a real {@link Bundle} implementation
41
+ * and no system service at all.
42
+ */
43
+ @RunWith(RobolectricTestRunner.class)
44
+ public class GetDisplayedNotificationsDataTest {
45
+
46
+ // -------- shouldIncludeInData (pure predicate) --------
47
+
48
+ @Test
49
+ public void shouldIncludeInData_plainKey_returnsTrue() {
50
+ assertTrue(NotificationManager.shouldIncludeInData("event"));
51
+ assertTrue(NotificationManager.shouldIncludeInData("userId"));
52
+ assertTrue(NotificationManager.shouldIncludeInData("conversation_id"));
53
+ }
54
+
55
+ @Test
56
+ public void shouldIncludeInData_androidPrefix_returnsFalse() {
57
+ assertFalse(NotificationManager.shouldIncludeInData("android.title"));
58
+ assertFalse(NotificationManager.shouldIncludeInData("android.text"));
59
+ }
60
+
61
+ @Test
62
+ public void shouldIncludeInData_androidLookalike_returnsTrue() {
63
+ // The `android.` prefix intentionally includes the dot, so custom keys that
64
+ // merely begin with the substring `android` are not accidentally filtered.
65
+ assertTrue(NotificationManager.shouldIncludeInData("androidify"));
66
+ assertTrue(NotificationManager.shouldIncludeInData("androidish"));
67
+ }
68
+
69
+ @Test
70
+ public void shouldIncludeInData_googlePrefix_returnsFalse() {
71
+ assertFalse(NotificationManager.shouldIncludeInData("google.sent_time"));
72
+ assertFalse(NotificationManager.shouldIncludeInData("google.delivered_priority"));
73
+ }
74
+
75
+ @Test
76
+ public void shouldIncludeInData_googleLookalike_returnsTrue() {
77
+ // Same reasoning as androidLookalike — `google.` with dot keeps the filter precise.
78
+ assertTrue(NotificationManager.shouldIncludeInData("googleish"));
79
+ assertTrue(NotificationManager.shouldIncludeInData("googlebot"));
80
+ }
81
+
82
+ @Test
83
+ public void shouldIncludeInData_gcmPrefix_returnsFalse() {
84
+ assertFalse(NotificationManager.shouldIncludeInData("gcm.notification.foo"));
85
+ assertFalse(NotificationManager.shouldIncludeInData("gcm.n.e"));
86
+ }
87
+
88
+ @Test
89
+ public void shouldIncludeInData_notifeePrefix_returnsFalse() {
90
+ // "notifee.notification" and "notifee.trigger" are the current internal
91
+ // constants (EXTRA_NOTIFEE_NOTIFICATION / EXTRA_NOTIFEE_TRIGGER). They
92
+ // are caught by the no-dot `notifee` prefix.
93
+ assertFalse(NotificationManager.shouldIncludeInData("notifee.notification"));
94
+ assertFalse(NotificationManager.shouldIncludeInData("notifee.trigger"));
95
+
96
+ // `notifee` is INTENTIONALLY kept without the dot: the library reserves
97
+ // its own namespace entirely, so `notifeeFoo` is also filtered. This
98
+ // differs from `fcm.` (with dot, see below): for `notifee` the whole
99
+ // prefix is library-owned and can collide with future internal constants,
100
+ // whereas `fcm` is a third-party namespace where we prefer to let
101
+ // realistic user keys like `fcmRegion` or `fcmToken` survive.
102
+ assertFalse(NotificationManager.shouldIncludeInData("notifeeFoo"));
103
+ }
104
+
105
+ @Test
106
+ public void shouldIncludeInData_fcmDottedPrefix_returnsFalse() {
107
+ // `fcm.` uses the trailing dot so realistic user custom keys (fcmRegion,
108
+ // fcmToken, fcmlike) can round-trip through `data`. This diverges from
109
+ // iOS parseDataFromUserInfo:, which uses bare `[key hasPrefix:@"fcm"]`
110
+ // specifically to catch `fcm_options` (see the `// fcm_options` marker
111
+ // at NotifeeCoreUtil.m:627-628 and the dedicated
112
+ // shouldIncludeInData_fcmOptionsExactKey_returnsFalse test above). Both
113
+ // platforms drop `fcm_options`; Android additionally preserves bare-fcm
114
+ // user keys (fcmRegion, fcmToken, …) that iOS drops as collateral.
115
+ assertFalse(NotificationManager.shouldIncludeInData("fcm.notification"));
116
+ assertFalse(NotificationManager.shouldIncludeInData("fcm.foo"));
117
+ }
118
+
119
+ @Test
120
+ public void shouldIncludeInData_fcmLookalike_returnsTrue() {
121
+ // These survive the Android filter. They would NOT survive the iOS
122
+ // filter, which is an accepted cross-platform divergence in favor of
123
+ // preserving realistic user data on Android. Note: `fcm_options` is a
124
+ // deliberate EXCEPTION to this rule — see the dedicated test
125
+ // shouldIncludeInData_fcmOptionsExactKey_returnsFalse below.
126
+ assertTrue(NotificationManager.shouldIncludeInData("fcm"));
127
+ assertTrue(NotificationManager.shouldIncludeInData("fcmlike"));
128
+ assertTrue(NotificationManager.shouldIncludeInData("fcmRegion"));
129
+ assertTrue(NotificationManager.shouldIncludeInData("fcmToken"));
130
+ }
131
+
132
+ @Test
133
+ public void shouldIncludeInData_fcmOptionsExactKey_returnsFalse() {
134
+ // `fcm_options` is the Firebase HTTP v1 Message.fcm_options analytics
135
+ // label. On iOS it is caught by the bare `[key hasPrefix:@"fcm"]` filter
136
+ // in NotifeeCoreUtil.m:627-628, which has an inline `// fcm_options`
137
+ // comment documenting that the whole reason the iOS prefix is bare (not
138
+ // dotted) is specifically to drop this key. On Android we switched the
139
+ // prefix to `fcm.` (with dot) so realistic user keys like `fcmRegion`
140
+ // can survive — but that alone would leak `fcm_options`. This test
141
+ // guards the exact-match entry in EXCLUDED_DATA_KEYS that restores
142
+ // parity with iOS for the one Firebase-reserved bare-`fcm` key.
143
+ assertFalse(NotificationManager.shouldIncludeInData("fcm_options"));
144
+ }
145
+
146
+ @Test
147
+ public void shouldIncludeInData_exactKeyBlocklist_returnsFalse() {
148
+ assertFalse(NotificationManager.shouldIncludeInData("from"));
149
+ assertFalse(NotificationManager.shouldIncludeInData("collapse_key"));
150
+ assertFalse(NotificationManager.shouldIncludeInData("message_type"));
151
+ assertFalse(NotificationManager.shouldIncludeInData("message_id"));
152
+ assertFalse(NotificationManager.shouldIncludeInData("aps"));
153
+ }
154
+
155
+ @Test
156
+ public void shouldIncludeInData_nullKey_returnsFalse() {
157
+ assertFalse(NotificationManager.shouldIncludeInData(null));
158
+ }
159
+
160
+ // -------- extractDataFromExtras (bundle → bundle) --------
161
+
162
+ @Test
163
+ public void extractDataFromExtras_nullExtras_returnsEmptyBundle() {
164
+ Bundle result = NotificationManager.extractDataFromExtras(null);
165
+ assertNotNull(result);
166
+ assertEquals(0, result.size());
167
+ }
168
+
169
+ @Test
170
+ public void extractDataFromExtras_emptyExtras_returnsEmptyBundle() {
171
+ Bundle result = NotificationManager.extractDataFromExtras(new Bundle());
172
+ assertNotNull(result);
173
+ assertEquals(0, result.size());
174
+ }
175
+
176
+ @Test
177
+ public void extractDataFromExtras_mixedKeys_returnsOnlyCustomKeys() {
178
+ Bundle extras = new Bundle();
179
+
180
+ // Custom keys that should survive the filter.
181
+ extras.putString("event", "chat_msg");
182
+ extras.putString("userId", "42");
183
+
184
+ // System keys that must be dropped.
185
+ extras.putString("android.title", "Hello");
186
+ extras.putString("android.text", "Body");
187
+ extras.putString("google.sent_time", "123456");
188
+ extras.putString("gcm.notification.e", "1");
189
+ extras.putString("from", "12345");
190
+ extras.putString("collapse_key", "do_not_collapse");
191
+ extras.putString("message_id", "msg-abc");
192
+ extras.putString("notifee.notification", "internal");
193
+ extras.putString("fcm.foo", "reserved-dotted");
194
+
195
+ // Custom key matching the iOS-divergent survivor pattern: `fcmRegion`
196
+ // must appear in the result on Android, even though iOS would drop it.
197
+ extras.putString("fcmRegion", "eu-west-1");
198
+
199
+ // Non-String value to verify toString() coercion for the rare survivor.
200
+ extras.putInt("count", 5);
201
+
202
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
203
+ assertNotNull(result);
204
+
205
+ Set<String> expected = new HashSet<>(Arrays.asList("event", "userId", "fcmRegion", "count"));
206
+ assertEquals(expected, result.keySet());
207
+ assertEquals("chat_msg", result.getString("event"));
208
+ assertEquals("42", result.getString("userId"));
209
+ assertEquals("eu-west-1", result.getString("fcmRegion"));
210
+ assertEquals("5", result.getString("count"));
211
+ }
212
+
213
+ @Test
214
+ public void extractDataFromExtras_onlySystemKeys_returnsEmptyBundle() {
215
+ Bundle extras = new Bundle();
216
+ extras.putString("android.title", "Hello");
217
+ extras.putString("google.sent_time", "123");
218
+ extras.putString("gcm.notification.foo", "bar");
219
+ extras.putString("notifee.trigger", "internal");
220
+ extras.putString("from", "12345");
221
+ extras.putString("fcm.options", "reserved-dotted");
222
+ // Firebase analytics label — exact-match filtered for iOS parity,
223
+ // see shouldIncludeInData_fcmOptionsExactKey_returnsFalse.
224
+ extras.putString("fcm_options", "{\"analytics_label\":\"foo\"}");
225
+
226
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
227
+ assertNotNull(result);
228
+ assertEquals(0, result.size());
229
+ }
230
+
231
+ @Test
232
+ public void extractDataFromExtras_nullValueForCustomKey_isSkipped() {
233
+ Bundle extras = new Bundle();
234
+ extras.putString("event", "chat_msg");
235
+ extras.putString("nullable", null);
236
+
237
+ Bundle result = NotificationManager.extractDataFromExtras(extras);
238
+ assertNotNull(result);
239
+ assertEquals(1, result.size());
240
+ assertEquals("chat_msg", result.getString("event"));
241
+ }
242
+ }
@@ -0,0 +1,81 @@
1
+ package app.notifee.core;
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
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import static org.junit.Assert.assertFalse;
20
+ import static org.junit.Assert.assertTrue;
21
+
22
+ import org.junit.Test;
23
+ import org.junit.runner.RunWith;
24
+ import org.robolectric.RobolectricTestRunner;
25
+
26
+ /**
27
+ * Unit tests for the BOOT_COUNT-based cold-start reschedule decision logic added for upstream
28
+ * invertase/notifee#734. Exhaustively covers the pure decision helper {@link
29
+ * InitProvider#shouldRescheduleAfterBoot}.
30
+ *
31
+ * <p>The integration path {@code runBootCheck → rescheduleNotifications} is intentionally not
32
+ * exercised here because {@code rescheduleNotifications} touches a real Room database via {@code
33
+ * WorkDataRepository.getInstance}, which is not set up in this unit-test environment. That path is
34
+ * covered by the instrumented {@code RebootRecoveryTest} and by the manual Step 4 smoke test.
35
+ */
36
+ @RunWith(RobolectricTestRunner.class)
37
+ public class InitProviderBootCheckTest {
38
+
39
+ @Test
40
+ public void shouldRescheduleAfterBoot_firstRun_withValidBootCount_returnsFalse() {
41
+ // lastKnown == -1 means "no baseline recorded yet". We record the baseline
42
+ // and skip reschedule to avoid firing stale triggers on fresh installs.
43
+ assertFalse(InitProvider.shouldRescheduleAfterBoot(5, -1));
44
+ }
45
+
46
+ @Test
47
+ public void shouldRescheduleAfterBoot_sameBoot_returnsFalse() {
48
+ // Same app process, same device boot → nothing to recover.
49
+ assertFalse(InitProvider.shouldRescheduleAfterBoot(5, 5));
50
+ }
51
+
52
+ @Test
53
+ public void shouldRescheduleAfterBoot_newBoot_returnsTrue() {
54
+ // Reboot detected since last run → recover scheduled alarms.
55
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(6, 5));
56
+ }
57
+
58
+ @Test
59
+ public void shouldRescheduleAfterBoot_bootCountDecreased_returnsTrue() {
60
+ // BOOT_COUNT decreasing is unexpected but possible (factory reset, counter
61
+ // rollover on exotic ROMs). Any change is treated as "unknown transition
62
+ // since last run" → conservative reschedule.
63
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(3, 5));
64
+ }
65
+
66
+ @Test
67
+ public void shouldRescheduleAfterBoot_bootCountUnavailable_returnsTrue() {
68
+ // BOOT_COUNT couldn't be read → we can't tell if a reboot happened, so we
69
+ // reschedule conservatively. This also covers emulators and rooted ROMs
70
+ // where the Settings.Global row is missing or throws.
71
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, 5));
72
+ }
73
+
74
+ @Test
75
+ public void shouldRescheduleAfterBoot_bootCountUnavailable_andFirstRun_returnsTrue() {
76
+ // Degenerate case: first run AND BOOT_COUNT unavailable. The "unavailable"
77
+ // branch wins over the "first run" branch because we genuinely don't know
78
+ // if the device has rebooted since install.
79
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, -1));
80
+ }
81
+ }