react-native-notify-kit 10.9.0 → 10.10.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 (30) hide show
  1. package/README.md +18 -0
  2. package/android/src/main/AndroidManifest.xml +11 -0
  3. package/android/src/main/java/app/notifee/core/EventBus.java +4 -0
  4. package/android/src/main/java/app/notifee/core/FullScreenNotificationActivity.java +76 -0
  5. package/android/src/main/java/app/notifee/core/InitialNotificationLedger.java +262 -0
  6. package/android/src/main/java/app/notifee/core/Notifee.java +142 -20
  7. package/android/src/main/java/app/notifee/core/NotificationManager.java +44 -6
  8. package/android/src/main/java/app/notifee/core/NotificationPendingIntent.java +14 -0
  9. package/android/src/main/java/app/notifee/core/NotificationReceiverHandler.java +11 -2
  10. package/android/src/main/java/app/notifee/core/event/InitialNotificationEvent.java +15 -0
  11. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +23 -0
  12. package/android/src/test/java/app/notifee/core/NotifeeGetInitialNotificationTest.java +3805 -0
  13. package/android/src/test/java/io/invertase/notifee/PermissionRationaleTest.java +144 -0
  14. package/cli/package.json +1 -1
  15. package/dist/NotifeeApiModule.d.ts +1 -0
  16. package/dist/NotifeeApiModule.js +6 -0
  17. package/dist/NotifeeApiModule.js.map +1 -1
  18. package/dist/specs/NativeNotifeeModule.d.ts +1 -0
  19. package/dist/specs/NativeNotifeeModule.js.map +1 -1
  20. package/dist/types/Module.d.ts +29 -3
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/dist/version.js.map +1 -1
  24. package/ios/RNNotifee/NotifeeApiModule.mm +5 -0
  25. package/jest-mock.js +1 -0
  26. package/package.json +1 -1
  27. package/src/NotifeeApiModule.ts +8 -0
  28. package/src/specs/NativeNotifeeModule.ts +1 -0
  29. package/src/types/Module.ts +30 -3
  30. package/src/version.ts +1 -1
@@ -0,0 +1,3805 @@
1
+ package app.notifee.core;
2
+
3
+ import static app.notifee.core.NotificationPendingIntent.EVENT_TYPE_INTENT_KEY;
4
+ import static app.notifee.core.NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY;
5
+ import static org.junit.Assert.assertEquals;
6
+ import static org.junit.Assert.assertFalse;
7
+ import static org.junit.Assert.assertNotEquals;
8
+ import static org.junit.Assert.assertNotNull;
9
+ import static org.junit.Assert.assertNotSame;
10
+ import static org.junit.Assert.assertNull;
11
+ import static org.junit.Assert.assertSame;
12
+ import static org.junit.Assert.assertTrue;
13
+ import static org.mockito.ArgumentMatchers.any;
14
+ import static org.mockito.ArgumentMatchers.anyString;
15
+ import static org.mockito.Mockito.mock;
16
+ import static org.mockito.Mockito.mockStatic;
17
+ import static org.mockito.Mockito.verify;
18
+
19
+ import android.Manifest;
20
+ import android.app.Activity;
21
+ import android.app.Notification;
22
+ import android.app.NotificationChannel;
23
+ import android.app.PendingIntent;
24
+ import android.app.RemoteInput;
25
+ import android.content.ComponentName;
26
+ import android.content.Context;
27
+ import android.content.Intent;
28
+ import android.content.SharedPreferences;
29
+ import android.content.pm.ActivityInfo;
30
+ import android.content.pm.ResolveInfo;
31
+ import android.os.BadParcelableException;
32
+ import android.os.Build;
33
+ import android.os.Bundle;
34
+ import app.notifee.core.event.InitialNotificationEvent;
35
+ import app.notifee.core.event.MainComponentEvent;
36
+ import app.notifee.core.event.NotificationEvent;
37
+ import app.notifee.core.model.NotificationModel;
38
+ import com.facebook.react.bridge.ActivityEventListener;
39
+ import com.facebook.react.bridge.Arguments;
40
+ import com.facebook.react.bridge.BaseActivityEventListener;
41
+ import com.facebook.react.bridge.Callback;
42
+ import com.facebook.react.bridge.CatalystInstance;
43
+ import com.facebook.react.bridge.JavaScriptContextHolder;
44
+ import com.facebook.react.bridge.JavaScriptModule;
45
+ import com.facebook.react.bridge.NativeModule;
46
+ import com.facebook.react.bridge.Promise;
47
+ import com.facebook.react.bridge.ReactApplicationContext;
48
+ import com.facebook.react.bridge.UIManager;
49
+ import com.facebook.react.bridge.WritableMap;
50
+ import com.facebook.react.module.model.ReactModuleInfo;
51
+ import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder;
52
+ import com.google.common.util.concurrent.Futures;
53
+ import com.google.common.util.concurrent.ListenableFuture;
54
+ import io.invertase.notifee.NotifeeApiModule;
55
+ import io.invertase.notifee.NotifeePackage;
56
+ import java.io.File;
57
+ import java.time.Duration;
58
+ import java.util.ArrayList;
59
+ import java.util.Collection;
60
+ import java.util.Collections;
61
+ import java.util.List;
62
+ import java.util.Random;
63
+ import java.util.UUID;
64
+ import java.util.concurrent.Callable;
65
+ import java.util.concurrent.CountDownLatch;
66
+ import java.util.concurrent.CyclicBarrier;
67
+ import java.util.concurrent.ExecutorService;
68
+ import java.util.concurrent.Executors;
69
+ import java.util.concurrent.Future;
70
+ import java.util.concurrent.TimeUnit;
71
+ import java.util.concurrent.atomic.AtomicBoolean;
72
+ import java.util.concurrent.atomic.AtomicReference;
73
+ import org.greenrobot.eventbus.Subscribe;
74
+ import org.junit.After;
75
+ import org.junit.Before;
76
+ import org.junit.Test;
77
+ import org.junit.runner.RunWith;
78
+ import org.mockito.ArgumentCaptor;
79
+ import org.mockito.MockedStatic;
80
+ import org.mockito.Mockito;
81
+ import org.robolectric.Robolectric;
82
+ import org.robolectric.RobolectricTestRunner;
83
+ import org.robolectric.RuntimeEnvironment;
84
+ import org.robolectric.Shadows;
85
+ import org.robolectric.android.controller.ActivityController;
86
+ import org.robolectric.annotation.Config;
87
+ import org.robolectric.shadows.ShadowContextImpl;
88
+ import org.robolectric.shadows.ShadowNotificationManager;
89
+ import org.robolectric.shadows.ShadowPackageManager;
90
+ import org.robolectric.shadows.ShadowPausedSystemClock;
91
+ import org.robolectric.shadows.ShadowPendingIntent;
92
+ import org.robolectric.shadows.ShadowSystemClock;
93
+
94
+ /**
95
+ * Behavioral tests for {@link Notifee#getInitialNotification} one-shot consumption (#88).
96
+ *
97
+ * <p>Interactions are produced by the production display path ({@link
98
+ * NotificationManager#displayNotification}) and the intents saved in the resulting PendingIntents.
99
+ * A tap is simulated the way the system delivers it on Android 12+: {@link
100
+ * NotificationReceiverActivity}, on top of the PendingIntent stack, runs first and posts the sticky
101
+ * event; the launch Activity then receives its own copy of the launch Intent. A full-screen launch
102
+ * uses a sole private capture Activity, which starts the consumer after recording the launch.
103
+ * Synthetic callback races also exercise the Activity fallback defensively.
104
+ */
105
+ @RunWith(RobolectricTestRunner.class)
106
+ @Config(sdk = 34)
107
+ public class NotifeeGetInitialNotificationTest {
108
+ private static final String CHANNEL_ID = "initial-notification-consumption-test-channel";
109
+ // More later interactions than a short record of claimed ids would hold.
110
+ private static final int LATER_INTERACTIONS = 40;
111
+ // Action of the receiver Intent of a full-screen launch: part of its PendingIntent's identity.
112
+ private static final String FULL_SCREEN_LAUNCH_ACTION = "app.notifee.core.FULL_SCREEN_LAUNCH";
113
+
114
+ private Context context;
115
+ // React instance hosting ReactLaunchActivity, once started (startReactInstance).
116
+ private TestReactContext reactContext;
117
+ private NotifeeApiModule notifeeModule;
118
+ private NotificationEventRecorder eventRecorder;
119
+
120
+ @Before
121
+ public void setUp() {
122
+ context = RuntimeEnvironment.getApplication();
123
+ ContextHolder.setApplicationContext(context);
124
+ setTargetSdk(Build.VERSION_CODES.S);
125
+ ShadowPendingIntent.reset();
126
+ clearStickyEvents();
127
+ // Each test starts as a fresh app process with fresh app storage.
128
+ Notifee.resetInitialNotificationStateForTesting();
129
+ InitialNotificationLedger.clearForTesting();
130
+
131
+ Shadows.shadowOf(RuntimeEnvironment.getApplication())
132
+ .grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
133
+ registerDefaultLaunchActivity();
134
+ // These consumer classes stand in for manifest-declared, same-app Activities.
135
+ ShadowPackageManager packageManager = Shadows.shadowOf(context.getPackageManager());
136
+ for (Class<?> activity :
137
+ new Class<?>[] {
138
+ SetIntentLaunchActivity.class,
139
+ ComponentCheckingLaunchActivity.class,
140
+ ReactLaunchActivity.class,
141
+ ReactExtrasClearingLaunchActivity.class,
142
+ ReactSetIntentLaunchActivity.class
143
+ }) {
144
+ packageManager.addActivityIfNotPresent(new ComponentName(context, activity));
145
+ }
146
+ createChannel();
147
+ nativeNotificationManager().cancelAll();
148
+ }
149
+
150
+ @After
151
+ public void tearDown() {
152
+ ContextHolder.setApplicationContext(context);
153
+ clearStickyEvents();
154
+ Notifee.resetInitialNotificationStateForTesting();
155
+ InitialNotificationLedger.clearForTesting();
156
+ ShadowPendingIntent.reset();
157
+ ReactLaunchActivity.reactContext = null;
158
+ if (eventRecorder != null) {
159
+ EventBus.unregister(eventRecorder);
160
+ }
161
+ }
162
+
163
+ // --- C1: one-shot consumption ---
164
+
165
+ @Test
166
+ public void stickyEventAndLaunchIntentOfSameTap_areReturnedOnce() throws Exception {
167
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
168
+
169
+ Activity activity = launch(DefaultLaunchActivity.class, tap(flowA)).get();
170
+
171
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
172
+ assertNull(getInitialNotification(activity));
173
+ assertNull(getInitialNotification(activity));
174
+ }
175
+
176
+ @Test
177
+ public void launchIntentOfUnrecordedTap_isNotAnInteractionOnItsOwn() throws Exception {
178
+ // A launch Intent whose tap the receiver never recorded is indistinguishable from one read
179
+ // before its sticky event (W1); the tap is returned by its event. A tap whose event was lost
180
+ // with its process is covered by tapNotConsumedBeforeProcessDeath_*.
181
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
182
+
183
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(flowA[0])).get();
184
+
185
+ assertNull(getInitialNotification(activity));
186
+ assertNull(getInitialNotification(activity));
187
+ }
188
+
189
+ @Test
190
+ public void consumption_leavesActivityIntentExtrasIntact() throws Exception {
191
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
192
+ Activity activity = launch(DefaultLaunchActivity.class, tap(flowA)).get();
193
+
194
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
195
+ assertNull(getInitialNotification(activity));
196
+
197
+ Intent intent = activity.getIntent();
198
+ assertEquals("A", intent.getBundleExtra("notification").getString("id"));
199
+ assertTrue(intent.hasExtra(EVENT_TYPE_INTENT_KEY));
200
+ assertTrue(intent.hasExtra(NotificationPendingIntent.NOTIFICATION_ID_INTENT_KEY));
201
+ assertNotNull(intent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY));
202
+ assertNotNull(intent.getBundleExtra("pressAction"));
203
+ }
204
+
205
+ // --- C2/C3: issue #88 and later interactions ---
206
+
207
+ @Test
208
+ public void issue88_laterTapThroughOnNewIntent_doesNotReplayColdStartNotification()
209
+ throws Exception {
210
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
211
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
212
+
213
+ // Cold start from A.
214
+ ActivityController<DefaultLaunchActivity> controller =
215
+ launch(DefaultLaunchActivity.class, tap(flowA));
216
+ Activity activity = controller.get();
217
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
218
+ // Back through Recents.
219
+ assertNull(getInitialNotification(activity));
220
+
221
+ // Tap B from the background: delivered through onNewIntent, getIntent() still holds A.
222
+ controller.newIntent(tap(flowB));
223
+ assertEquals("A", activity.getIntent().getBundleExtra("notification").getString("id"));
224
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
225
+ // Back through Recents: A must not be returned again.
226
+ assertNull(getInitialNotification(activity));
227
+ assertNull(getInitialNotification(activity));
228
+ }
229
+
230
+ @Test
231
+ public void laterTapCreatingNewActivityInstance_isReturnedOnce() throws Exception {
232
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
233
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
234
+ Activity first = launch(DefaultLaunchActivity.class, tap(flowA)).get();
235
+ assertInitialNotification(getInitialNotification(first), "A", "default");
236
+
237
+ Activity second = launch(DefaultLaunchActivity.class, tap(flowB)).get();
238
+
239
+ assertInitialNotification(getInitialNotification(second), "B", "default");
240
+ assertNull(getInitialNotification(second));
241
+ assertNull(getInitialNotification(first));
242
+ }
243
+
244
+ @Test
245
+ public void laterTapDeliveredThroughSetIntent_isReturnedOnce() throws Exception {
246
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
247
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
248
+ ActivityController<SetIntentLaunchActivity> controller =
249
+ launch(SetIntentLaunchActivity.class, tap(flowA));
250
+ Activity activity = controller.get();
251
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
252
+ assertNull(getInitialNotification(activity));
253
+
254
+ controller.newIntent(tap(flowB));
255
+ assertEquals("B", activity.getIntent().getBundleExtra("notification").getString("id"));
256
+
257
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
258
+ assertNull(getInitialNotification(activity));
259
+ }
260
+
261
+ @Test
262
+ public void relaunchFromRecentsInSameProcess_doesNotReplayConsumedNotification()
263
+ throws Exception {
264
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
265
+ ActivityController<DefaultLaunchActivity> controller =
266
+ launch(DefaultLaunchActivity.class, tap(flowA));
267
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
268
+ controller.pause().stop().destroy();
269
+
270
+ // The task root was finished; Recents starts a new instance with a new copy of the task's
271
+ // base Intent while the process is still alive.
272
+ Intent fromRecents = new Intent(flowA[0]).addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY);
273
+ Activity relaunched = launch(DefaultLaunchActivity.class, fromRecents).get();
274
+
275
+ assertNull(getInitialNotification(relaunched));
276
+ }
277
+
278
+ @Test
279
+ public void retapOfSameNotification_isANewValidInteraction() throws Exception {
280
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
281
+ ActivityController<DefaultLaunchActivity> controller =
282
+ launch(DefaultLaunchActivity.class, tap(flowA));
283
+ Activity activity = controller.get();
284
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
285
+ assertNull(getInitialNotification(activity));
286
+
287
+ // Same PendingIntent tapped again (autoCancel disabled).
288
+ controller.newIntent(tap(flowA));
289
+
290
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
291
+ assertNull(getInitialNotification(activity));
292
+ }
293
+
294
+ @Test
295
+ public void redisplayedNotificationWithSameId_isANewValidInteraction() throws Exception {
296
+ Intent[] firstFlow = launchFlow(display(pressNotification("A")).contentIntent);
297
+ ActivityController<SetIntentLaunchActivity> controller =
298
+ launch(SetIntentLaunchActivity.class, tap(firstFlow));
299
+ Activity activity = controller.get();
300
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
301
+ assertNull(getInitialNotification(activity));
302
+
303
+ Intent[] secondFlow = launchFlow(display(pressNotification("A")).contentIntent);
304
+ controller.newIntent(tap(secondFlow));
305
+
306
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
307
+ assertNull(getInitialNotification(activity));
308
+ }
309
+
310
+ // --- C3: source availability order and concurrency ---
311
+
312
+ @Test
313
+ public void launchIntentObservedBeforeItsStickyEvent_isReturnedOnce() throws Exception {
314
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
315
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
316
+ ActivityController<SetIntentLaunchActivity> controller =
317
+ launch(SetIntentLaunchActivity.class, tap(flowA));
318
+ Activity activity = controller.get();
319
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
320
+
321
+ // B reaches the (resumed) launch Activity before NotificationReceiverActivity posts B.
322
+ controller.newIntent(new Intent(flowB[0]));
323
+ assertNull(getInitialNotification(activity));
324
+ runReceiver(flowB);
325
+
326
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
327
+ assertNull(getInitialNotification(activity));
328
+ }
329
+
330
+ @Test
331
+ public void stickyEventConsumedBeforeLaunchIntentArrives_isReturnedOnce() throws Exception {
332
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
333
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
334
+ ActivityController<SetIntentLaunchActivity> controller =
335
+ launch(SetIntentLaunchActivity.class, tap(flowA));
336
+ Activity activity = controller.get();
337
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
338
+
339
+ runReceiver(flowB);
340
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
341
+ controller.newIntent(new Intent(flowB[0]));
342
+
343
+ assertNull(getInitialNotification(activity));
344
+ }
345
+
346
+ @Test
347
+ public void concurrentCalls_returnTheInteractionExactlyOnce() throws Exception {
348
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
349
+ Activity activity = launch(DefaultLaunchActivity.class, tap(flowA)).get();
350
+
351
+ int callers = 8;
352
+ CyclicBarrier start = new CyclicBarrier(callers);
353
+ ExecutorService executor = Executors.newFixedThreadPool(callers);
354
+ try {
355
+ List<Future<Bundle>> results = new ArrayList<>();
356
+ for (int i = 0; i < callers; i++) {
357
+ results.add(
358
+ executor.submit(
359
+ () -> {
360
+ start.await(5, TimeUnit.SECONDS);
361
+ return getInitialNotification(activity);
362
+ }));
363
+ }
364
+
365
+ List<Bundle> delivered = new ArrayList<>();
366
+ for (Future<Bundle> result : results) {
367
+ Bundle bundle = result.get(5, TimeUnit.SECONDS);
368
+ if (bundle != null) {
369
+ delivered.add(bundle);
370
+ }
371
+ }
372
+ assertEquals("exactly one concurrent caller receives A", 1, delivered.size());
373
+ assertInitialNotification(delivered.get(0), "A", "default");
374
+ } finally {
375
+ executor.shutdownNow();
376
+ }
377
+ assertNull(getInitialNotification(activity));
378
+ }
379
+
380
+ // --- W1: Intent observed before its sticky event in a process with no consumed sticky ---
381
+
382
+ @Test
383
+ public void w1_laterTapDeliveredThroughSetIntentBeforeItsStickyEvent_isReturnedOnce()
384
+ throws Exception {
385
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
386
+ ActivityController<SetIntentLaunchActivity> controller =
387
+ launch(SetIntentLaunchActivity.class, launcherIntent());
388
+ Activity activity = controller.get();
389
+ assertNull(getInitialNotification(activity));
390
+
391
+ // onNewIntent(A)/setIntent(A) reaches the launch Activity before the receiver posts A.
392
+ controller.newIntent(new Intent(flowA[0]));
393
+ List<Bundle> delivered = new ArrayList<>();
394
+ collect(delivered, getInitialNotification(activity));
395
+ runReceiver(flowA);
396
+ collect(delivered, getInitialNotification(activity));
397
+ collect(delivered, getInitialNotification(activity));
398
+
399
+ assertDeliveredOnce(delivered, "A");
400
+ }
401
+
402
+ @Test
403
+ public void w1_coldStartIntentReadBeforeItsStickyEvent_isReturnedOnce() throws Exception {
404
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
405
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(flowA[0])).get();
406
+
407
+ List<Bundle> delivered = new ArrayList<>();
408
+ collect(delivered, getInitialNotification(activity));
409
+ runReceiver(flowA);
410
+ collect(delivered, getInitialNotification(activity));
411
+ collect(delivered, getInitialNotification(activity));
412
+
413
+ assertDeliveredOnce(delivered, "A");
414
+ }
415
+
416
+ @Test
417
+ public void w1_receiverRacingConsumer_deliversEveryTapExactlyOnce() throws Exception {
418
+ Intent[] flowA = launchFlow(display(nonAutoCancelPressNotification("A")).contentIntent);
419
+ ActivityController<SetIntentLaunchActivity> controller =
420
+ launch(SetIntentLaunchActivity.class, launcherIntent());
421
+ Activity activity = controller.get();
422
+ controller.newIntent(new Intent(flowA[0]));
423
+
424
+ ExecutorService executor = Executors.newFixedThreadPool(2);
425
+ try {
426
+ for (int tap = 0; tap < 100; tap++) {
427
+ CyclicBarrier start = new CyclicBarrier(2);
428
+ Future<?> receiver =
429
+ executor.submit(
430
+ () -> {
431
+ start.await(5, TimeUnit.SECONDS);
432
+ NotificationReceiverHandler.handleNotification(context, new Intent(flowA[1]));
433
+ return null;
434
+ });
435
+ Future<List<Bundle>> consumer =
436
+ executor.submit(
437
+ () -> {
438
+ start.await(5, TimeUnit.SECONDS);
439
+ List<Bundle> results = new ArrayList<>();
440
+ for (int i = 0; i < 20; i++) {
441
+ collect(results, getInitialNotification(activity));
442
+ }
443
+ return results;
444
+ });
445
+ receiver.get(5, TimeUnit.SECONDS);
446
+ List<Bundle> delivered = consumer.get(5, TimeUnit.SECONDS);
447
+ collect(delivered, getInitialNotification(activity));
448
+ collect(delivered, getInitialNotification(activity));
449
+
450
+ assertEquals("tap " + tap + " must be delivered exactly once", 1, delivered.size());
451
+ assertInitialNotification(delivered.get(0), "A", null);
452
+ }
453
+ } finally {
454
+ executor.shutdownNow();
455
+ }
456
+ }
457
+
458
+ // --- W2: process death and Activity restore ---
459
+
460
+ @Test
461
+ public void w2a_restoreWithSavedStateAfterProcessDeath_doesNotReplayConsumedTap()
462
+ throws Exception {
463
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
464
+ ActivityController<DefaultLaunchActivity> controller =
465
+ launch(DefaultLaunchActivity.class, tap(flowA));
466
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
467
+ Bundle savedState = moveToBackground(controller);
468
+
469
+ simulateProcessDeath();
470
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
471
+
472
+ assertNull(getInitialNotification(restored));
473
+ assertNull(getInitialNotification(restored));
474
+ }
475
+
476
+ @Test
477
+ public void w2a_setIntentHostRestoredAfterProcessDeath_doesNotReplayCreationTap()
478
+ throws Exception {
479
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
480
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
481
+ ActivityController<SetIntentLaunchActivity> controller =
482
+ launch(SetIntentLaunchActivity.class, tap(flowA));
483
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
484
+ controller.newIntent(tap(flowB));
485
+ assertInitialNotification(getInitialNotification(controller.get()), "B", "default");
486
+ Bundle savedState = moveToBackground(controller);
487
+
488
+ simulateProcessDeath();
489
+ // The system restores the Activity with its creation Intent, not the one set by setIntent().
490
+ Activity restored = restore(SetIntentLaunchActivity.class, new Intent(flowA[0]), savedState);
491
+
492
+ assertNull(getInitialNotification(restored));
493
+ }
494
+
495
+ @Test
496
+ public void w2a_restoredIntentOfAnotherNotification_doesNotStandInForTheLatestTap()
497
+ throws Exception {
498
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
499
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
500
+ ActivityController<DefaultLaunchActivity> controller =
501
+ launch(DefaultLaunchActivity.class, tap(flowA));
502
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
503
+ // B is tapped while the app runs; JS handles its press event and never asks for it here.
504
+ controller.newIntent(tap(flowB));
505
+ Bundle savedState = moveToBackground(controller);
506
+
507
+ simulateProcessDeath();
508
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
509
+
510
+ // A was returned already; B's Intent did not survive, so A's Intent cannot stand in for it.
511
+ assertNull(getInitialNotification(restored));
512
+ }
513
+
514
+ @Test
515
+ public void w2b_relaunchFromRecentsAfterProcessDeath_doesNotReplayConsumedTap() throws Exception {
516
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
517
+ ActivityController<DefaultLaunchActivity> controller =
518
+ launch(DefaultLaunchActivity.class, tap(flowA));
519
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
520
+ controller.pause().stop().destroy();
521
+
522
+ simulateProcessDeath();
523
+ Intent fromRecents = new Intent(flowA[0]).addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY);
524
+ Activity relaunched = launch(DefaultLaunchActivity.class, fromRecents).get();
525
+
526
+ assertNull(getInitialNotification(relaunched));
527
+ }
528
+
529
+ @Test
530
+ public void w2c_successiveRestoresWithNewIntentObjects_neverReplayConsumedTap() throws Exception {
531
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
532
+ ActivityController<DefaultLaunchActivity> controller =
533
+ launch(DefaultLaunchActivity.class, tap(flowA));
534
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
535
+
536
+ for (int restore = 0; restore < 3; restore++) {
537
+ Bundle savedState = moveToBackground(controller);
538
+ simulateProcessDeath();
539
+ controller = restoreController(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
540
+ assertNull("restore " + restore, getInitialNotification(controller.get()));
541
+ }
542
+ }
543
+
544
+ @Test
545
+ public void tapNotConsumedBeforeProcessDeath_isReturnedOnceAcrossRestores() throws Exception {
546
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
547
+ ActivityController<DefaultLaunchActivity> controller =
548
+ launch(DefaultLaunchActivity.class, tap(flowA));
549
+ // The process dies before JS asks for the initial notification.
550
+ Bundle savedState = moveToBackground(controller);
551
+
552
+ simulateProcessDeath();
553
+ controller = restoreController(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
554
+ assertInitialNotification(getInitialNotification(controller.get()), "A", null);
555
+ assertNull(getInitialNotification(controller.get()));
556
+
557
+ savedState = moveToBackground(controller);
558
+ simulateProcessDeath();
559
+ controller = restoreController(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
560
+ assertNull(getInitialNotification(controller.get()));
561
+ }
562
+
563
+ @Test
564
+ public void tapsAfterRestore_includingSameNotification_areNewValidInteractions()
565
+ throws Exception {
566
+ Intent[] flowA = launchFlow(display(nonAutoCancelPressNotification("A")).contentIntent);
567
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
568
+ ActivityController<DefaultLaunchActivity> controller =
569
+ launch(DefaultLaunchActivity.class, tap(flowA));
570
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
571
+ Bundle savedState = moveToBackground(controller);
572
+
573
+ simulateProcessDeath();
574
+ controller = restoreController(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
575
+ Activity restored = controller.get();
576
+ assertNull(getInitialNotification(restored));
577
+
578
+ controller.newIntent(tap(flowB));
579
+ assertInitialNotification(getInitialNotification(restored), "B", "default");
580
+ assertNull(getInitialNotification(restored));
581
+
582
+ controller.newIntent(tap(flowA));
583
+ assertInitialNotification(getInitialNotification(restored), "A", "default");
584
+ assertNull(getInitialNotification(restored));
585
+
586
+ Intent[] redisplayedA = launchFlow(display(pressNotification("A")).contentIntent);
587
+ controller.newIntent(tap(redisplayedA));
588
+ assertInitialNotification(getInitialNotification(restored), "A", "default");
589
+ assertNull(getInitialNotification(restored));
590
+ }
591
+
592
+ @Test
593
+ public void stickyEventPostedAfterItsTapWasReturnedThroughTheIntent_isDropped() throws Exception {
594
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
595
+ Activity activity = launch(DefaultLaunchActivity.class, launcherIntent()).get();
596
+ activity.setIntent(new Intent(flowA[0]));
597
+
598
+ // The receiver recorded the tap; its event is not in the sticky slot yet.
599
+ runReceiver(flowA);
600
+ InitialNotificationEvent eventA = EventBus.removeStickEvent(InitialNotificationEvent.class);
601
+ assertInitialNotification(getInitialNotification(activity), "A", null);
602
+
603
+ EventBus.postSticky(eventA);
604
+ assertNull(getInitialNotification(activity));
605
+ assertNull(getInitialNotification(null));
606
+ }
607
+
608
+ @Test
609
+ public void concurrentCallsAfterProcessDeath_returnTheLostTapExactlyOnce() throws Exception {
610
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
611
+ Bundle savedState = moveToBackground(launch(DefaultLaunchActivity.class, tap(flowA)));
612
+ simulateProcessDeath();
613
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
614
+
615
+ int callers = 8;
616
+ CyclicBarrier start = new CyclicBarrier(callers);
617
+ ExecutorService executor = Executors.newFixedThreadPool(callers);
618
+ try {
619
+ List<Future<Bundle>> results = new ArrayList<>();
620
+ for (int i = 0; i < callers; i++) {
621
+ results.add(
622
+ executor.submit(
623
+ () -> {
624
+ start.await(5, TimeUnit.SECONDS);
625
+ return getInitialNotification(restored);
626
+ }));
627
+ }
628
+ List<Bundle> delivered = new ArrayList<>();
629
+ for (Future<Bundle> result : results) {
630
+ collect(delivered, result.get(5, TimeUnit.SECONDS));
631
+ }
632
+ assertDeliveredOnce(delivered, "A");
633
+ } finally {
634
+ executor.shutdownNow();
635
+ }
636
+ }
637
+
638
+ @Test
639
+ public void launchIntentWithoutPendingIntentId_isNeverReturnedFromTheIntent() throws Exception {
640
+ // PendingIntents created before the id existed (e.g. displayed by a previous app version).
641
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
642
+ Intent launchIntent = new Intent(flowA[0]);
643
+ launchIntent.removeExtra(PENDING_INTENT_ID_INTENT_KEY);
644
+ Intent receiverIntent = new Intent(flowA[1]);
645
+ receiverIntent.removeExtra(PENDING_INTENT_ID_INTENT_KEY);
646
+
647
+ runReceiver(receiverIntent);
648
+ ActivityController<DefaultLaunchActivity> controller =
649
+ launch(DefaultLaunchActivity.class, launchIntent);
650
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
651
+ assertNull(getInitialNotification(controller.get()));
652
+
653
+ Bundle savedState = moveToBackground(controller);
654
+ simulateProcessDeath();
655
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(launchIntent), savedState);
656
+ assertNull(getInitialNotification(restored));
657
+ }
658
+
659
+ @Test
660
+ public void nullActivityIntent_returnsNull() {
661
+ Activity activity = launch(DefaultLaunchActivity.class, launcherIntent()).get();
662
+ activity.setIntent(null);
663
+
664
+ assertNull(getInitialNotification(activity));
665
+ }
666
+
667
+ @Test
668
+ @Config(sdk = 31)
669
+ public void api31Boundary_restoreAfterProcessDeath_doesNotReplayConsumedTap() throws Exception {
670
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
671
+ ActivityController<DefaultLaunchActivity> controller =
672
+ launch(DefaultLaunchActivity.class, tap(flowA));
673
+ assertInitialNotification(getInitialNotification(controller.get()), "A", "default");
674
+ Bundle savedState = moveToBackground(controller);
675
+
676
+ simulateProcessDeath();
677
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(flowA[0]), savedState);
678
+
679
+ assertNull(getInitialNotification(restored));
680
+ }
681
+
682
+ // --- W3: Intents without the marker (full-screen) ---
683
+
684
+ @Test
685
+ public void w3_fullScreenActivityRecreatedWithNewIntentObject_doesNotReplay() throws Exception {
686
+ Intent fullScreenIntent =
687
+ fullScreenLaunchIntent(
688
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
689
+ ActivityController<DefaultLaunchActivity> controller =
690
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent));
691
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
692
+ controller.pause().stop().destroy();
693
+
694
+ // Same process: Recents starts a new instance with a new copy of the base Intent.
695
+ Intent fromRecents =
696
+ new Intent(fullScreenIntent).addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY);
697
+ Activity relaunched = launch(DefaultLaunchActivity.class, fromRecents).get();
698
+
699
+ assertNull(getInitialNotification(relaunched));
700
+ }
701
+
702
+ @Test
703
+ public void w3_fullScreenActivityRestoredAfterProcessDeath_doesNotReplay() throws Exception {
704
+ Intent fullScreenIntent =
705
+ fullScreenLaunchIntent(
706
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
707
+ ActivityController<DefaultLaunchActivity> controller =
708
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent));
709
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
710
+
711
+ for (int restore = 0; restore < 2; restore++) {
712
+ Bundle savedState = moveToBackground(controller);
713
+ simulateProcessDeath();
714
+ controller =
715
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenIntent), savedState);
716
+ assertNull("restore " + restore, getInitialNotification(controller.get()));
717
+ }
718
+ }
719
+
720
+ @Test
721
+ @Config(sdk = 28)
722
+ public void api28_fullScreenActivityRestoredAfterProcessDeath_doesNotReplay() throws Exception {
723
+ Intent fullScreenIntent =
724
+ fullScreenLaunchIntent(
725
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
726
+ ActivityController<DefaultLaunchActivity> controller =
727
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent));
728
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
729
+ Bundle savedState = moveToBackground(controller);
730
+
731
+ simulateProcessDeath();
732
+ Activity restored =
733
+ restore(DefaultLaunchActivity.class, new Intent(fullScreenIntent), savedState);
734
+
735
+ assertNull(getInitialNotification(restored));
736
+ }
737
+
738
+ @Test
739
+ public void w3_redisplayedFullScreenNotification_isANewValidInteraction() throws Exception {
740
+ Intent first =
741
+ fullScreenLaunchIntent(
742
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
743
+ ActivityController<DefaultLaunchActivity> controller =
744
+ launch(DefaultLaunchActivity.class, new Intent(first));
745
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
746
+ Bundle savedState = moveToBackground(controller);
747
+ simulateProcessDeath();
748
+ controller = restoreController(DefaultLaunchActivity.class, new Intent(first), savedState);
749
+ assertNull(getInitialNotification(controller.get()));
750
+ controller.pause().stop().destroy();
751
+
752
+ // Same notification id displayed again: its full-screen launch is a new interaction.
753
+ Intent second =
754
+ fullScreenLaunchIntent(
755
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
756
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(second)).get();
757
+
758
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
759
+ assertNull(getInitialNotification(activity));
760
+ }
761
+
762
+ @Test
763
+ public void fullScreenIntentNotConsumedBeforeProcessDeath_isReturnedOnceAfterRestore()
764
+ throws Exception {
765
+ Intent fullScreenIntent =
766
+ fullScreenLaunchIntent(
767
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
768
+ ActivityController<DefaultLaunchActivity> controller =
769
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent));
770
+ Bundle savedState = moveToBackground(controller);
771
+
772
+ simulateProcessDeath();
773
+ controller =
774
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenIntent), savedState);
775
+
776
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
777
+ assertNull(getInitialNotification(controller.get()));
778
+ }
779
+
780
+ // --- R-1: full-screen claims are kept whatever the number of later interactions ---
781
+
782
+ @Test
783
+ public void r1_consumedFullScreenRestoredAfterManyLaterClaimsAndProcessDeath_isNotReplayed()
784
+ throws Exception {
785
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
786
+ ActivityController<DefaultLaunchActivity> controller =
787
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenA));
788
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen-A", null);
789
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
790
+ consumeFullScreenLaunch("full-screen-" + i);
791
+ }
792
+ Bundle savedState = moveToBackground(controller);
793
+
794
+ simulateProcessDeath();
795
+ Activity restored = restore(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
796
+
797
+ assertNull(getInitialNotification(restored));
798
+ }
799
+
800
+ @Test
801
+ public void r1_activityKeepingConsumedFullScreenIntent_doesNotReplayItAfterManyLaterClaims()
802
+ throws Exception {
803
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
804
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenA)).get();
805
+ assertInitialNotification(getInitialNotification(activity), "full-screen-A", null);
806
+
807
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
808
+ consumeFullScreenLaunch("full-screen-" + i);
809
+ }
810
+
811
+ assertNull(getInitialNotification(activity));
812
+ }
813
+
814
+ @Test
815
+ public void r1_consumedFullScreenIntents_areNotReplayedAfterHundredsOfLaterClaims()
816
+ throws Exception {
817
+ List<Intent> consumed = new ArrayList<>();
818
+ ActivityController<SetIntentLaunchActivity> controller =
819
+ launch(SetIntentLaunchActivity.class, launcherIntent());
820
+ for (int i = 0; i < 300; i++) {
821
+ Intent fullScreenIntent = fullScreenIntent("full-screen-" + i);
822
+ controller.newIntent(new Intent(fullScreenIntent));
823
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen-" + i, null);
824
+ consumed.add(fullScreenIntent);
825
+ }
826
+
827
+ simulateProcessDeath();
828
+ for (int i = 0; i < consumed.size(); i++) {
829
+ Activity restored = launch(DefaultLaunchActivity.class, new Intent(consumed.get(i))).get();
830
+ assertNull("full-screen-" + i, getInitialNotification(restored));
831
+ }
832
+ }
833
+
834
+ @Test
835
+ public void r1_fullScreenIntentsClaimedOutOfOrderAcrossProcessDeaths_areEachReturnedOnce()
836
+ throws Exception {
837
+ int count = 80;
838
+ List<Intent> fullScreenIntents = new ArrayList<>();
839
+ for (int i = 0; i < count; i++) {
840
+ fullScreenIntents.add(fullScreenIntent("full-screen-" + i));
841
+ }
842
+ int[] deliveries = new int[count];
843
+ Random random = new Random(88);
844
+
845
+ // Each round presents the Intents in a new order, in new Activities, dying every few claims.
846
+ for (int round = 0; round < 3; round++) {
847
+ List<Integer> order = new ArrayList<>();
848
+ for (int i = 0; i < count; i++) {
849
+ order.add(i);
850
+ }
851
+ Collections.shuffle(order, random);
852
+ // The first round leaves a third of the launches unconsumed.
853
+ List<Integer> presented = round == 0 ? order.subList(0, count * 2 / 3) : order;
854
+ for (int n = 0; n < presented.size(); n++) {
855
+ int i = presented.get(n);
856
+ Activity activity =
857
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntents.get(i))).get();
858
+ Bundle initialNotification = getInitialNotification(activity);
859
+ if (initialNotification != null) {
860
+ assertInitialNotification(initialNotification, "full-screen-" + i, null);
861
+ deliveries[i]++;
862
+ }
863
+ if (n % 7 == 0) {
864
+ simulateProcessDeath();
865
+ }
866
+ }
867
+ }
868
+
869
+ for (int i = 0; i < count; i++) {
870
+ assertEquals("full-screen-" + i + " deliveries", 1, deliveries[i]);
871
+ }
872
+ }
873
+
874
+ @Test
875
+ public void r1_launchedButUnconsumedFullScreen_staysClaimableAfterManyLaterInteractions()
876
+ throws Exception {
877
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
878
+ ActivityController<DefaultLaunchActivity> controller =
879
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenA));
880
+ // A's Activity goes to the background before JS asks for the initial notification.
881
+ Bundle savedState = moveToBackground(controller);
882
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
883
+ consumeFullScreenLaunch("full-screen-" + i);
884
+ Intent[] flow = launchFlow(display(pressNotification("tap-" + i)).contentIntent);
885
+ Activity tapped = launch(DefaultLaunchActivity.class, tap(flow)).get();
886
+ assertInitialNotification(getInitialNotification(tapped), "tap-" + i, "default");
887
+ }
888
+
889
+ simulateProcessDeath();
890
+ controller =
891
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
892
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen-A", null);
893
+ assertNull(getInitialNotification(controller.get()));
894
+
895
+ savedState = moveToBackground(controller);
896
+ simulateProcessDeath();
897
+ controller =
898
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
899
+ assertNull(getInitialNotification(controller.get()));
900
+ }
901
+
902
+ @Test
903
+ public void r1_displayedButNotYetLaunchedFullScreen_staysClaimableAfterManyLaterClaims()
904
+ throws Exception {
905
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
906
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
907
+ consumeFullScreenLaunch("full-screen-" + i);
908
+ }
909
+ simulateProcessDeath();
910
+
911
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenA)).get();
912
+
913
+ assertInitialNotification(getInitialNotification(activity), "full-screen-A", null);
914
+ assertNull(getInitialNotification(activity));
915
+ }
916
+
917
+ @Test
918
+ public void r1_redisplaysWithSameNotificationId_neverReviveAnOlderConsumedLaunch()
919
+ throws Exception {
920
+ Intent first = fullScreenIntent("full-screen");
921
+ ActivityController<DefaultLaunchActivity> controller =
922
+ launch(DefaultLaunchActivity.class, new Intent(first));
923
+ assertInitialNotification(getInitialNotification(controller.get()), "full-screen", null);
924
+ Bundle savedState = moveToBackground(controller);
925
+
926
+ List<Intent> later = new ArrayList<>();
927
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
928
+ later.add(consumeFullScreenLaunch("full-screen"));
929
+ }
930
+ // Displayed again, not launched yet.
931
+ Intent latest = fullScreenIntent("full-screen");
932
+
933
+ simulateProcessDeath();
934
+ assertNull(
935
+ getInitialNotification(
936
+ restore(DefaultLaunchActivity.class, new Intent(first), savedState)));
937
+ for (Intent intent : later) {
938
+ assertNull(getInitialNotification(launch(DefaultLaunchActivity.class, intent).get()));
939
+ }
940
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(latest)).get();
941
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
942
+ assertNull(getInitialNotification(activity));
943
+ }
944
+
945
+ @Test
946
+ public void r1_concurrentClaimsOfOneFullScreenIntent_returnItOnce() throws Exception {
947
+ Activity activity =
948
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent("full-screen-A"))).get();
949
+
950
+ int callers = 8;
951
+ CyclicBarrier start = new CyclicBarrier(callers);
952
+ ExecutorService executor = Executors.newFixedThreadPool(callers);
953
+ try {
954
+ List<Future<Bundle>> results = new ArrayList<>();
955
+ for (int i = 0; i < callers; i++) {
956
+ results.add(
957
+ executor.submit(
958
+ () -> {
959
+ start.await(5, TimeUnit.SECONDS);
960
+ return getInitialNotification(activity);
961
+ }));
962
+ }
963
+ List<Bundle> delivered = new ArrayList<>();
964
+ for (Future<Bundle> result : results) {
965
+ collect(delivered, result.get(5, TimeUnit.SECONDS));
966
+ }
967
+ assertDeliveredOnce(delivered, "full-screen-A");
968
+ } finally {
969
+ executor.shutdownNow();
970
+ }
971
+ }
972
+
973
+ @Test
974
+ public void r1_concurrentFullScreenDisplays_areEachReturnedOnce() throws Exception {
975
+ int count = 24;
976
+ List<NotificationModel> models = new ArrayList<>();
977
+ List<ListenableFuture<Void>> displays = new ArrayList<>();
978
+ for (int i = 0; i < count; i++) {
979
+ NotificationModel model = fullScreenNotification("full-screen-" + i, "FullScreenComponent");
980
+ models.add(model);
981
+ displays.add(NotificationManager.displayNotification(model, null));
982
+ }
983
+ Futures.allAsList(displays).get(10, TimeUnit.SECONDS);
984
+
985
+ List<Intent> fullScreenIntents = new ArrayList<>();
986
+ for (NotificationModel model : models) {
987
+ Notification notification =
988
+ Shadows.shadowOf(nativeNotificationManager()).getNotification(model.getHashCode());
989
+ fullScreenIntents.add(new Intent(fullScreenLaunchIntent(notification.fullScreenIntent)));
990
+ }
991
+ for (int i = 0; i < count; i++) {
992
+ Activity activity =
993
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntents.get(i))).get();
994
+ assertInitialNotification(getInitialNotification(activity), "full-screen-" + i, null);
995
+ }
996
+
997
+ simulateProcessDeath();
998
+ for (Intent intent : fullScreenIntents) {
999
+ assertNull(getInitialNotification(launch(DefaultLaunchActivity.class, intent).get()));
1000
+ }
1001
+ }
1002
+
1003
+ @Test
1004
+ public void r1_fullScreenClaimThatCannotBePersisted_isReturnedOnceAfterProcessDeath()
1005
+ throws Exception {
1006
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
1007
+ ActivityController<DefaultLaunchActivity> controller =
1008
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenA));
1009
+ Activity activity = controller.get();
1010
+ List<Bundle> delivered = new ArrayList<>();
1011
+
1012
+ withFailingPreferenceWrites(
1013
+ () -> {
1014
+ collect(delivered, getInitialNotification(activity));
1015
+ collect(delivered, getInitialNotification(activity));
1016
+ return null;
1017
+ });
1018
+ Bundle savedState = moveToBackground(controller);
1019
+ simulateProcessDeath();
1020
+ controller =
1021
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
1022
+ collect(delivered, getInitialNotification(controller.get()));
1023
+ collect(delivered, getInitialNotification(controller.get()));
1024
+
1025
+ savedState = moveToBackground(controller);
1026
+ simulateProcessDeath();
1027
+ controller =
1028
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
1029
+ collect(delivered, getInitialNotification(controller.get()));
1030
+
1031
+ assertDeliveredOnce(delivered, "full-screen-A");
1032
+ }
1033
+
1034
+ @Test
1035
+ public void r1_fullScreenClaimThatCannotBePersisted_survivesALaterSuccessfulWrite()
1036
+ throws Exception {
1037
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
1038
+ ActivityController<DefaultLaunchActivity> controller =
1039
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenA));
1040
+ Activity activity = controller.get();
1041
+ List<Bundle> delivered = new ArrayList<>();
1042
+
1043
+ withFailingPreferenceWrites(
1044
+ () -> {
1045
+ collect(delivered, getInitialNotification(activity));
1046
+ return null;
1047
+ });
1048
+ // Storage works again and is written for an unrelated interaction.
1049
+ consumeFullScreenLaunch("full-screen-B");
1050
+ Bundle savedState = moveToBackground(controller);
1051
+ simulateProcessDeath();
1052
+ controller =
1053
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
1054
+ collect(delivered, getInitialNotification(controller.get()));
1055
+ collect(delivered, getInitialNotification(controller.get()));
1056
+
1057
+ assertDeliveredOnce(delivered, "full-screen-A");
1058
+ }
1059
+
1060
+ @Test
1061
+ public void r1_fullScreenDisplayedWhileStorageFails_isReturnedOnce() throws Exception {
1062
+ Intent fullScreenA = withFailingPreferenceWrites(() -> fullScreenIntent("full-screen-A"));
1063
+ ActivityController<DefaultLaunchActivity> controller =
1064
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenA));
1065
+ List<Bundle> delivered = new ArrayList<>();
1066
+ collect(delivered, getInitialNotification(controller.get()));
1067
+ collect(delivered, getInitialNotification(controller.get()));
1068
+
1069
+ Bundle savedState = moveToBackground(controller);
1070
+ simulateProcessDeath();
1071
+ controller =
1072
+ restoreController(DefaultLaunchActivity.class, new Intent(fullScreenA), savedState);
1073
+ collect(delivered, getInitialNotification(controller.get()));
1074
+
1075
+ // Its id is held in memory by the process that displayed it.
1076
+ assertDeliveredOnce(delivered, "full-screen-A");
1077
+ }
1078
+
1079
+ @Test
1080
+ public void r1_unclaimedFullScreenIdsOfTheCurrentBoot_areKeptAsTimePasses() throws Exception {
1081
+ Intent fullScreenA = fullScreenIntent("full-screen-A");
1082
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
1083
+ ShadowSystemClock.advanceBy(Duration.ofHours(12));
1084
+ ShadowSystemClock.simulateDeepSleep(Duration.ofHours(12));
1085
+ fullScreenIntent("full-screen-" + i);
1086
+ simulateProcessDeath();
1087
+ }
1088
+ assertEquals(
1089
+ LATER_INTERACTIONS + 1,
1090
+ InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1091
+
1092
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenA)).get();
1093
+
1094
+ assertInitialNotification(getInitialNotification(activity), "full-screen-A", null);
1095
+ assertNull(getInitialNotification(activity));
1096
+ }
1097
+
1098
+ @Test
1099
+ public void r1_unclaimedFullScreenIdsIssuedBeforeAReboot_areDroppedWithTheNextIssue()
1100
+ throws Exception {
1101
+ ShadowSystemClock.advanceBy(Duration.ofDays(3));
1102
+ for (int i = 0; i < LATER_INTERACTIONS; i++) {
1103
+ fullScreenIntent("full-screen-" + i);
1104
+ }
1105
+ assertEquals(
1106
+ LATER_INTERACTIONS, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1107
+
1108
+ // Reboot: the process, its notifications and the system's Intent extras are gone, uptime
1109
+ // restarts.
1110
+ simulateProcessDeath();
1111
+ nativeNotificationManager().cancelAll();
1112
+ ShadowPausedSystemClock.reset();
1113
+ Intent fullScreenB = fullScreenIntent("full-screen-B");
1114
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1115
+ Intent fullScreenC = fullScreenIntent("full-screen-C");
1116
+ assertEquals(2, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1117
+
1118
+ Activity activityB = launch(DefaultLaunchActivity.class, new Intent(fullScreenB)).get();
1119
+ assertInitialNotification(getInitialNotification(activityB), "full-screen-B", null);
1120
+ Activity activityC = launch(DefaultLaunchActivity.class, new Intent(fullScreenC)).get();
1121
+ assertInitialNotification(getInitialNotification(activityC), "full-screen-C", null);
1122
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1123
+ }
1124
+
1125
+ // --- Null activity, missing/malformed extras, foreign intents ---
1126
+
1127
+ @Test
1128
+ public void nullActivity_returnsStickyEventOnceThenNull() throws Exception {
1129
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
1130
+ assertNull(getInitialNotification(null));
1131
+
1132
+ runReceiver(flowA);
1133
+
1134
+ assertInitialNotification(getInitialNotification(null), "A", "default");
1135
+ assertNull(getInitialNotification(null));
1136
+ }
1137
+
1138
+ @Test
1139
+ public void activityIntentWithoutNotification_returnsNullAndKeepsExtras() {
1140
+ Intent launcherIntent = new Intent(Intent.ACTION_MAIN).putExtra("deep_link", "app://home");
1141
+ Activity activity = launch(DefaultLaunchActivity.class, launcherIntent).get();
1142
+
1143
+ assertNull(getInitialNotification(activity));
1144
+ assertEquals("app://home", activity.getIntent().getStringExtra("deep_link"));
1145
+ }
1146
+
1147
+ @Test
1148
+ public void malformedNotificationExtra_returnsNull() {
1149
+ Intent intent = new Intent(Intent.ACTION_MAIN).putExtra("notification", "not-a-bundle");
1150
+ Activity activity = launch(DefaultLaunchActivity.class, intent).get();
1151
+
1152
+ assertNull(getInitialNotification(activity));
1153
+ assertNull(getInitialNotification(activity));
1154
+ assertEquals("not-a-bundle", activity.getIntent().getStringExtra("notification"));
1155
+ }
1156
+
1157
+ @Test
1158
+ public void foreignIntentWithNotificationBundle_isReturnedOnceAndLeftIntact() {
1159
+ Bundle foreignNotification = new Bundle();
1160
+ foreignNotification.putString("id", "foreign");
1161
+ Intent intent =
1162
+ new Intent(Intent.ACTION_VIEW)
1163
+ .putExtra("notification", foreignNotification)
1164
+ .putExtra("deep_link", "app://foreign");
1165
+ Activity activity = launch(DefaultLaunchActivity.class, intent).get();
1166
+
1167
+ assertInitialNotification(getInitialNotification(activity), "foreign", null);
1168
+ assertNull(getInitialNotification(activity));
1169
+ assertEquals("foreign", activity.getIntent().getBundleExtra("notification").getString("id"));
1170
+ assertEquals("app://foreign", activity.getIntent().getStringExtra("deep_link"));
1171
+ }
1172
+
1173
+ // --- C4: regression anchors ---
1174
+
1175
+ @Test
1176
+ public void everyLaunchFlowInteraction_postsStickyEventAndMarksLaunchIntent() throws Exception {
1177
+ List<Intent[]> flows = new ArrayList<>();
1178
+ flows.add(launchFlow(display(pressNotification("press-default")).contentIntent));
1179
+ flows.add(
1180
+ launchFlow(
1181
+ display(
1182
+ notification(
1183
+ "press-main-component",
1184
+ androidWithPressAction(
1185
+ pressAction("default", "default", "PressMainComponent"))))
1186
+ .contentIntent));
1187
+ flows.add(
1188
+ launchFlow(
1189
+ display(actionNotification("action-launch", pressAction("open", "default", null)))
1190
+ .actions[0]
1191
+ .actionIntent));
1192
+ flows.add(
1193
+ launchFlow(
1194
+ display(
1195
+ actionNotification(
1196
+ "action-main-component",
1197
+ pressAction("open-main", null, "ActionMainComponent")))
1198
+ .actions[0]
1199
+ .actionIntent));
1200
+
1201
+ List<String> pendingIntentIds = new ArrayList<>();
1202
+ long previousTapSequence = 0;
1203
+ for (Intent[] flow : flows) {
1204
+ String id = flow[0].getBundleExtra("notification").getString("id");
1205
+ assertTrue(id + " launch Intent carries the marker", flow[0].hasExtra(EVENT_TYPE_INTENT_KEY));
1206
+ String pendingIntentId = flow[0].getStringExtra(PENDING_INTENT_ID_INTENT_KEY);
1207
+ assertNotNull(id + " launch Intent carries the PendingIntent id", pendingIntentId);
1208
+ assertEquals(pendingIntentId, flow[1].getStringExtra(PENDING_INTENT_ID_INTENT_KEY));
1209
+ assertFalse(id + " PendingIntent ids are unique", pendingIntentIds.contains(pendingIntentId));
1210
+ pendingIntentIds.add(pendingIntentId);
1211
+
1212
+ runReceiver(flow);
1213
+
1214
+ InitialNotificationEvent event = EventBus.getStickyEvent(InitialNotificationEvent.class);
1215
+ assertNotNull(id + " receiver must post InitialNotificationEvent", event);
1216
+ assertEquals(id, event.getNotificationModel().getId());
1217
+ assertTrue(id + " tap is recorded", event.getTapSequence() > previousTapSequence);
1218
+ previousTapSequence = event.getTapSequence();
1219
+ clearStickyEvents();
1220
+ }
1221
+ }
1222
+
1223
+ @Test
1224
+ public void actionPressWithLaunchActivity_returnsPressActionAndInputOnce() throws Exception {
1225
+ Intent[] flow =
1226
+ launchFlow(
1227
+ display(actionNotification("action", pressAction("reply", "default", null)))
1228
+ .actions[0]
1229
+ .actionIntent);
1230
+
1231
+ Activity activity = launch(DefaultLaunchActivity.class, tapWithInput(flow, "Accepted")).get();
1232
+
1233
+ Bundle initialNotification = getInitialNotification(activity);
1234
+ assertInitialNotification(initialNotification, "action", "reply");
1235
+ assertEquals("Accepted", initialNotification.getString("input"));
1236
+ assertNull(getInitialNotification(activity));
1237
+ }
1238
+
1239
+ @Test
1240
+ public void fullScreenIntent_isReturnedOnceAndKeepsMainComponent() throws Exception {
1241
+ Intent fullScreenIntent =
1242
+ fullScreenLaunchIntent(
1243
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
1244
+ assertFalse(fullScreenIntent.hasExtra(EVENT_TYPE_INTENT_KEY));
1245
+
1246
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent)).get();
1247
+
1248
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
1249
+ assertNull(getInitialNotification(activity));
1250
+ assertNull(getInitialNotification(activity));
1251
+ assertEquals("FullScreenComponent", Notifee.getInstance().getMainComponent("DefaultComponent"));
1252
+ }
1253
+
1254
+ @Test
1255
+ public void fullScreenIntentConsumedThenLaterTap_returnsOnlyTheLaterTap() throws Exception {
1256
+ Intent fullScreenIntent =
1257
+ fullScreenLaunchIntent(
1258
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
1259
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
1260
+ ActivityController<DefaultLaunchActivity> controller =
1261
+ launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent));
1262
+ Activity activity = controller.get();
1263
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
1264
+
1265
+ controller.newIntent(tap(flowB));
1266
+
1267
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
1268
+ assertNull(getInitialNotification(activity));
1269
+ }
1270
+
1271
+ @Test
1272
+ public void fullScreenIntentAfterUnrelatedStickyEvent_isStillADistinctInteraction()
1273
+ throws Exception {
1274
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
1275
+ Intent fullScreenIntent =
1276
+ fullScreenLaunchIntent(
1277
+ display(fullScreenNotification("full-screen", "FullScreenComponent")).fullScreenIntent);
1278
+ runReceiver(flowB);
1279
+
1280
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent)).get();
1281
+
1282
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
1283
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
1284
+ assertNull(getInitialNotification(activity));
1285
+ }
1286
+
1287
+ @Test
1288
+ public void pressActionOptOut_hasNoInitialNotification() throws Exception {
1289
+ Notification notification =
1290
+ display(
1291
+ notification(
1292
+ "opt-out",
1293
+ androidWithPressAction(
1294
+ pressAction(NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID, null, null))));
1295
+ assertNull(notification.contentIntent);
1296
+
1297
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(Intent.ACTION_MAIN)).get();
1298
+
1299
+ assertNull(getInitialNotification(activity));
1300
+ }
1301
+
1302
+ @Test
1303
+ @Config(sdk = 31)
1304
+ public void api31Boundary_launchFlowIsReturnedOnce() throws Exception {
1305
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
1306
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
1307
+ ActivityController<DefaultLaunchActivity> controller =
1308
+ launch(DefaultLaunchActivity.class, tap(flowA));
1309
+ Activity activity = controller.get();
1310
+
1311
+ assertInitialNotification(getInitialNotification(activity), "A", "default");
1312
+ assertNull(getInitialNotification(activity));
1313
+ controller.newIntent(tap(flowB));
1314
+ assertInitialNotification(getInitialNotification(activity), "B", "default");
1315
+ assertNull(getInitialNotification(activity));
1316
+ }
1317
+
1318
+ @Test
1319
+ @Config(sdk = 30)
1320
+ public void preAndroid12ReceiverServicePress_isReturnedOnce() throws Exception {
1321
+ assertLegacyReceiverServicePressIsReturnedOnce();
1322
+ }
1323
+
1324
+ @Test
1325
+ public void targetSdkBelow31ReceiverServicePress_isReturnedOnce() throws Exception {
1326
+ setTargetSdk(Build.VERSION_CODES.R);
1327
+ assertLegacyReceiverServicePressIsReturnedOnce();
1328
+ }
1329
+
1330
+ private void assertLegacyReceiverServicePressIsReturnedOnce() throws Exception {
1331
+ PendingIntent contentIntent = display(pressNotification("legacy")).contentIntent;
1332
+ ShadowPendingIntent shadowContentIntent = Shadows.shadowOf(contentIntent);
1333
+ assertTrue("legacy press must route to ReceiverService", shadowContentIntent.isService());
1334
+
1335
+ Robolectric.buildService(ReceiverService.class)
1336
+ .create()
1337
+ .get()
1338
+ .onStartCommand(new Intent(shadowContentIntent.getSavedIntent()), 0, 1);
1339
+ Intent launchIntent =
1340
+ Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity();
1341
+ assertNotNull("ReceiverService must launch the app", launchIntent);
1342
+ assertFalse(launchIntent.hasExtra("notification"));
1343
+
1344
+ Activity activity = launch(DefaultLaunchActivity.class, launchIntent).get();
1345
+
1346
+ assertInitialNotification(getInitialNotification(activity), "legacy", "default");
1347
+ assertNull(getInitialNotification(activity));
1348
+ }
1349
+
1350
+ // --- F88-RT-01: launches delivered to an existing React Native Activity (onNewIntent) ---
1351
+
1352
+ @Test
1353
+ @Config(sdk = {28, 31, 34, 35, 36, 37})
1354
+ public void rt01_fullScreenDeliveredToExistingReactActivity_isReturnedOnce() throws Exception {
1355
+ startReactInstance();
1356
+ ActivityController<ReactLaunchActivity> controller =
1357
+ launch(ReactLaunchActivity.class, launcherIntent());
1358
+ assertNull(jsGetInitialNotification());
1359
+
1360
+ // singleTask: the system hands the launch to the existing instance.
1361
+ deliverFullScreen(controller, fullScreenFlow("FS-singleTask-new"));
1362
+
1363
+ Intent creationIntent = controller.get().getIntent();
1364
+ assertEquals(Intent.ACTION_MAIN, creationIntent.getAction());
1365
+ assertFalse(creationIntent.hasExtra("notification"));
1366
+ assertInitialNotification(jsGetInitialNotification(), "FS-singleTask-new", null);
1367
+ assertNull(jsGetInitialNotification());
1368
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1369
+ assertTrue(reactContext.listenerExceptions.isEmpty());
1370
+ }
1371
+
1372
+ @Test
1373
+ public void rt01_successiveFullScreens_includingSameNotificationId_areEachReturnedOnce()
1374
+ throws Exception {
1375
+ startReactInstance();
1376
+ ActivityController<ReactLaunchActivity> controller =
1377
+ launch(ReactLaunchActivity.class, launcherIntent());
1378
+
1379
+ for (int launch = 0; launch < 3; launch++) {
1380
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1381
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1382
+ assertNull("launch " + launch, jsGetInitialNotification());
1383
+ }
1384
+ deliverFullScreen(controller, fullScreenFlow("full-screen-A"));
1385
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-A", null);
1386
+ deliverFullScreen(controller, fullScreenFlow("full-screen-B"));
1387
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-B", null);
1388
+ assertNull(jsGetInitialNotification());
1389
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1390
+ }
1391
+
1392
+ @Test
1393
+ public void rt01_laterFullScreenBeforeARead_replacesTheUnreadOneLikeALaterTap() throws Exception {
1394
+ startReactInstance();
1395
+ ActivityController<ReactLaunchActivity> controller =
1396
+ launch(ReactLaunchActivity.class, launcherIntent());
1397
+
1398
+ deliverFullScreen(controller, fullScreenFlow("full-screen-A"));
1399
+ deliverFullScreen(controller, fullScreenFlow("full-screen-B"));
1400
+
1401
+ // One new launch is kept, as the sticky event keeps the latest tap.
1402
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-B", null);
1403
+ assertNull(jsGetInitialNotification());
1404
+ // A was superseded permanently, even though it was never returned.
1405
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1406
+ }
1407
+
1408
+ @Test
1409
+ public void rt01_coldStartFromFullScreen_onlyTheLatestUnreadLaunchIsReturned() throws Exception {
1410
+ startReactInstance();
1411
+ ActivityController<ReactLaunchActivity> controller =
1412
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("full-screen-A"));
1413
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-A", null);
1414
+ assertNull(jsGetInitialNotification());
1415
+
1416
+ deliverFullScreen(controller, fullScreenFlow("full-screen-B"));
1417
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-B", null);
1418
+ assertNull(jsGetInitialNotification());
1419
+ assertEquals(
1420
+ "full-screen-A",
1421
+ controller.get().getIntent().getBundleExtra("notification").getString("id"));
1422
+
1423
+ // Creation launch not read before a later one: only the latest may be returned.
1424
+ ActivityController<ReactLaunchActivity> unread =
1425
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("full-screen-C"));
1426
+ deliverFullScreen(unread, fullScreenFlow("full-screen-D"));
1427
+ List<Bundle> delivered = new ArrayList<>();
1428
+ for (int i = 0; i < 3; i++) {
1429
+ collect(delivered, jsGetInitialNotification());
1430
+ }
1431
+ assertEquals(1, delivered.size());
1432
+ assertInitialNotification(delivered.get(0), "full-screen-D", null);
1433
+ }
1434
+
1435
+ @Test
1436
+ public void rt01_fullScreenDeliveredBeforeJsFirstUsesNotifee_isReturnedOnce() throws Exception {
1437
+ startReactInstance();
1438
+ ActivityController<ReactLaunchActivity> controller =
1439
+ launch(ReactLaunchActivity.class, launcherIntent());
1440
+
1441
+ // JS has not called Notifee yet: it resolves the TurboModule on first use.
1442
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1443
+
1444
+ assertNull(notifeeModule);
1445
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1446
+ assertNull(jsGetInitialNotification());
1447
+ }
1448
+
1449
+ @Test
1450
+ public void rt01_reloadedReactInstance_keepsUnreadLaunchAndTakesOverLaterOnes() throws Exception {
1451
+ startReactInstance();
1452
+ ActivityController<ReactLaunchActivity> controller =
1453
+ launch(ReactLaunchActivity.class, launcherIntent());
1454
+ deliverFullScreen(controller, fullScreenFlow("full-screen-A"));
1455
+
1456
+ TestReactContext previous = reloadReactInstance(controller.get());
1457
+
1458
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-A", null);
1459
+ assertNull(jsGetInitialNotification());
1460
+ // A launch Intent handed to React Native only, here by the torn-down instance, is not one.
1461
+ previous.onNewIntent(
1462
+ controller.get(), fullScreenLaunchIntent(fullScreenFlow("full-screen-stale")));
1463
+ assertNull(jsGetInitialNotification());
1464
+ deliverFullScreen(controller, fullScreenFlow("full-screen-B"));
1465
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-B", null);
1466
+ assertNull(jsGetInitialNotification());
1467
+ }
1468
+
1469
+ @Test
1470
+ public void rt01_readFullScreen_isNotReplayedAfterActivityRecreation() throws Exception {
1471
+ startReactInstance();
1472
+ ActivityController<ReactLaunchActivity> controller =
1473
+ launch(ReactLaunchActivity.class, launcherIntent());
1474
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1475
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1476
+
1477
+ // Configuration change: a new instance gets the creation Intent; same process and instance.
1478
+ Bundle savedState = moveToBackground(controller);
1479
+ controller.destroy();
1480
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1481
+
1482
+ assertNull(jsGetInitialNotification());
1483
+ }
1484
+
1485
+ @Test
1486
+ public void rt01_unreadFullScreen_isReturnedOnceAfterActivityRecreation() throws Exception {
1487
+ startReactInstance();
1488
+ ActivityController<ReactLaunchActivity> controller =
1489
+ launch(ReactLaunchActivity.class, launcherIntent());
1490
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1491
+
1492
+ Bundle savedState = moveToBackground(controller);
1493
+ controller.destroy();
1494
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1495
+
1496
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1497
+ assertNull(jsGetInitialNotification());
1498
+ }
1499
+
1500
+ @Test
1501
+ public void rt01_readFullScreen_isNotReplayedAfterProcessDeath() throws Exception {
1502
+ startReactInstance();
1503
+ ActivityController<ReactLaunchActivity> controller =
1504
+ launch(ReactLaunchActivity.class, launcherIntent());
1505
+ Intent[] fullScreenFlow = fullScreenFlow("full-screen");
1506
+ deliverFullScreen(controller, fullScreenFlow);
1507
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1508
+ Bundle savedState = moveToBackground(controller);
1509
+
1510
+ simulateProcessDeath();
1511
+ startReactInstance();
1512
+ ActivityController<ReactLaunchActivity> restored =
1513
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1514
+ assertNull(jsGetInitialNotification());
1515
+
1516
+ // The same launch presented again, to the Activity, or as the base Intent from Recents.
1517
+ deliverFullScreen(restored, fullScreenFlow);
1518
+ assertNull(jsGetInitialNotification());
1519
+ launch(
1520
+ ReactLaunchActivity.class,
1521
+ fullScreenLaunchIntent(fullScreenFlow)
1522
+ .addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY));
1523
+ assertNull(jsGetInitialNotification());
1524
+ }
1525
+
1526
+ @Test
1527
+ public void rt01_launchNotReadBeforeProcessDeath_isReturnedOnceWhenPresentedAgain()
1528
+ throws Exception {
1529
+ startReactInstance();
1530
+ ActivityController<ReactLaunchActivity> controller =
1531
+ launch(ReactLaunchActivity.class, launcherIntent());
1532
+ Intent[] fullScreenFlow = fullScreenFlow("full-screen");
1533
+ deliverFullScreen(controller, fullScreenFlow);
1534
+ Bundle savedState = moveToBackground(controller);
1535
+
1536
+ simulateProcessDeath();
1537
+ startReactInstance();
1538
+ // The system restores the Activity with its creation Intent: the recorded launch died with the
1539
+ // process, its interaction was not consumed.
1540
+ ActivityController<ReactLaunchActivity> restored =
1541
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1542
+ assertNull(jsGetInitialNotification());
1543
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1544
+
1545
+ deliverFullScreen(restored, fullScreenFlow);
1546
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1547
+ assertNull(jsGetInitialNotification());
1548
+ deliverFullScreen(restored, fullScreenFlow);
1549
+ assertNull(jsGetInitialNotification());
1550
+ }
1551
+
1552
+ @Test
1553
+ public void rt01_otherNewIntents_areNeitherReturnedNorDropTheUnreadLaunch() throws Exception {
1554
+ startReactInstance();
1555
+ ActivityController<ReactLaunchActivity> controller =
1556
+ launch(ReactLaunchActivity.class, launcherIntent());
1557
+ Intent[] consumedFlow = fullScreenFlow("consumed");
1558
+ deliverFullScreen(controller, consumedFlow);
1559
+ assertInitialNotification(jsGetInitialNotification(), "consumed", null);
1560
+ Intent consumed = fullScreenLaunchIntent(consumedFlow);
1561
+ Intent legacyFullScreen = fullScreenLaunchIntent(fullScreenFlow("legacy"));
1562
+ legacyFullScreen.removeExtra(PENDING_INTENT_ID_INTENT_KEY);
1563
+ Bundle foreignNotification = new Bundle();
1564
+ foreignNotification.putString("id", "foreign");
1565
+
1566
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1567
+ List<Intent> others = new ArrayList<>();
1568
+ others.add(launcherIntent());
1569
+ others.add(new Intent(Intent.ACTION_VIEW).putExtra("notification", foreignNotification));
1570
+ others.add(
1571
+ new Intent(Intent.ACTION_VIEW)
1572
+ .putExtra("notification", foreignNotification)
1573
+ .putExtra(PENDING_INTENT_ID_INTENT_KEY, UUID.randomUUID().toString()));
1574
+ others.add(
1575
+ new Intent(Intent.ACTION_MAIN)
1576
+ .putExtra("notification", "not-a-bundle")
1577
+ .putExtra(PENDING_INTENT_ID_INTENT_KEY, UUID.randomUUID().toString()));
1578
+ others.add(new Intent(consumed).putExtra(PENDING_INTENT_ID_INTENT_KEY, 88));
1579
+ others.add(new Intent(consumed));
1580
+ others.add(new Intent(consumed).addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY));
1581
+ others.add(legacyFullScreen);
1582
+ others.add(new UnreadableExtrasIntent());
1583
+ for (Intent other : others) {
1584
+ controller.newIntent(other);
1585
+ }
1586
+
1587
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1588
+ assertNull(jsGetInitialNotification());
1589
+ assertNull(jsGetInitialNotification());
1590
+ assertEquals(Intent.ACTION_MAIN, controller.get().getIntent().getAction());
1591
+ assertTrue(reactContext.listenerExceptions.isEmpty());
1592
+ }
1593
+
1594
+ @Test
1595
+ public void rt01_recordedLaunch_isNotAffectedByLaterChangesToTheDeliveredIntents()
1596
+ throws Exception {
1597
+ startReactInstance();
1598
+ // Another SDK listening after Notifee, then the app, drop the extras they handled.
1599
+ reactContext.addActivityEventListener(
1600
+ new BaseActivityEventListener() {
1601
+ @Override
1602
+ public void onNewIntent(Intent intent) {
1603
+ intent.removeExtra("notification");
1604
+ }
1605
+ });
1606
+ ActivityController<ReactExtrasClearingLaunchActivity> controller =
1607
+ launch(ReactExtrasClearingLaunchActivity.class, launcherIntent());
1608
+ Intent[] flow = fullScreenFlow("full-screen", ReactExtrasClearingLaunchActivity.class);
1609
+
1610
+ Intent delivered = fullScreenLaunchIntent(flow);
1611
+ controller.newIntent(delivered);
1612
+ Intent receiverIntent = fullScreenReceiverIntent(flow);
1613
+ runReceiver(receiverIntent);
1614
+ receiverIntent.replaceExtras((Bundle) null);
1615
+
1616
+ assertNull(delivered.getExtras());
1617
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1618
+ assertNull(jsGetInitialNotification());
1619
+ }
1620
+
1621
+ @Test
1622
+ public void rt01_tapsDeliveredToExistingReactActivity_areReturnedOnce() throws Exception {
1623
+ startReactInstance();
1624
+ ActivityController<ReactLaunchActivity> controller =
1625
+ launch(ReactLaunchActivity.class, launcherIntent());
1626
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
1627
+ Intent[] flowB =
1628
+ launchFlow(
1629
+ display(actionNotification("B", pressAction("reply", "default", null)))
1630
+ .actions[0]
1631
+ .actionIntent);
1632
+ Intent[] flowC = launchFlow(display(pressNotification("C")).contentIntent);
1633
+
1634
+ controller.newIntent(tap(flowA));
1635
+ assertInitialNotification(jsGetInitialNotification(), "A", "default");
1636
+ assertNull(jsGetInitialNotification());
1637
+
1638
+ controller.newIntent(tapWithInput(flowB, "Accepted"));
1639
+ Bundle actionPress = jsGetInitialNotification();
1640
+ assertInitialNotification(actionPress, "B", "reply");
1641
+ assertEquals("Accepted", actionPress.getString("input"));
1642
+ assertNull(jsGetInitialNotification());
1643
+
1644
+ // The launch Intent reaches the Activity before the receiver posts the tap.
1645
+ controller.newIntent(new Intent(flowC[0]));
1646
+ assertNull(jsGetInitialNotification());
1647
+ runReceiver(flowC);
1648
+ assertInitialNotification(jsGetInitialNotification(), "C", "default");
1649
+ assertNull(jsGetInitialNotification());
1650
+ }
1651
+
1652
+ @Test
1653
+ public void rt01_tapAndFullScreenDeliveredBeforeARead_areEachReturnedOnce() throws Exception {
1654
+ startReactInstance();
1655
+ ActivityController<ReactLaunchActivity> controller =
1656
+ launch(ReactLaunchActivity.class, launcherIntent());
1657
+ Intent[] flowA = launchFlow(display(pressNotification("A")).contentIntent);
1658
+ Intent[] flowB = launchFlow(display(pressNotification("B")).contentIntent);
1659
+
1660
+ deliverFullScreen(controller, fullScreenFlow("full-screen-1"));
1661
+ controller.newIntent(tap(flowA));
1662
+ assertInitialNotification(jsGetInitialNotification(), "A", "default");
1663
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-1", null);
1664
+ assertNull(jsGetInitialNotification());
1665
+
1666
+ controller.newIntent(tap(flowB));
1667
+ deliverFullScreen(controller, fullScreenFlow("full-screen-2"));
1668
+ assertInitialNotification(jsGetInitialNotification(), "B", "default");
1669
+ assertInitialNotification(jsGetInitialNotification(), "full-screen-2", null);
1670
+ assertNull(jsGetInitialNotification());
1671
+ }
1672
+
1673
+ @Test
1674
+ public void rt01_launchDeliveredWhileAnotherReactActivityIsCurrent_isReturnedOnce()
1675
+ throws Exception {
1676
+ startReactInstance();
1677
+ ActivityController<ReactLaunchActivity> main =
1678
+ launch(ReactLaunchActivity.class, launcherIntent());
1679
+ // A second React Activity, created by a full-screen launch, is the current one.
1680
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("full-screen-A"));
1681
+
1682
+ deliverFullScreen(main, fullScreenFlow("full-screen-B"));
1683
+
1684
+ List<Bundle> delivered = new ArrayList<>();
1685
+ for (int i = 0; i < 3; i++) {
1686
+ collect(delivered, jsGetInitialNotification());
1687
+ }
1688
+ assertEquals(1, delivered.size());
1689
+ assertInitialNotification(delivered.get(0), "full-screen-B", null);
1690
+ }
1691
+
1692
+ @Test
1693
+ public void rt01_launchDelivered_isReturnedOnceWithoutCurrentActivity() throws Exception {
1694
+ startReactInstance();
1695
+ ActivityController<ReactLaunchActivity> controller =
1696
+ launch(ReactLaunchActivity.class, launcherIntent());
1697
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1698
+
1699
+ // The Activity is finished before JS asks; the React instance lives on.
1700
+ controller.pause().stop().destroy();
1701
+ assertNull(reactContext.getCurrentActivity());
1702
+
1703
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1704
+ assertNull(jsGetInitialNotification());
1705
+ }
1706
+
1707
+ @Test
1708
+ public void rt01_newIntentsRacingConcurrentReads_returnEveryLaunchOnce() throws Exception {
1709
+ assertConcurrentReadsReturnEveryLaunchOnce(ReactLaunchActivity.class);
1710
+ }
1711
+
1712
+ @Test
1713
+ public void rt01_newIntentsRacingConcurrentReadsOnSetIntentHost_returnEveryLaunchOnce()
1714
+ throws Exception {
1715
+ // The launch is both recorded by the receiver and returned by getIntent().
1716
+ assertConcurrentReadsReturnEveryLaunchOnce(ReactSetIntentLaunchActivity.class);
1717
+ }
1718
+
1719
+ @Test
1720
+ public void rt01_launchArrivingWhileTheKeptOneIsClaimed_isReturnedOnce() throws Exception {
1721
+ startReactInstance();
1722
+ ActivityController<ReactLaunchActivity> controller =
1723
+ launch(ReactLaunchActivity.class, launcherIntent());
1724
+ int rounds = 60;
1725
+ List<Intent[]> earlier = new ArrayList<>();
1726
+ List<Intent[]> later = new ArrayList<>();
1727
+ for (int round = 0; round < rounds; round++) {
1728
+ earlier.add(fullScreenFlow("earlier-" + round));
1729
+ later.add(fullScreenFlow("later-" + round));
1730
+ }
1731
+
1732
+ int readers = 4;
1733
+ AtomicBoolean delivering = new AtomicBoolean(true);
1734
+ List<Bundle> delivered = Collections.synchronizedList(new ArrayList<>());
1735
+ ExecutorService executor = Executors.newFixedThreadPool(readers);
1736
+ try {
1737
+ List<Future<?>> readerResults = new ArrayList<>();
1738
+ for (int i = 0; i < readers; i++) {
1739
+ readerResults.add(
1740
+ executor.submit(
1741
+ () -> {
1742
+ while (delivering.get()) {
1743
+ collect(delivered, getInitialNotification(reactContext.getCurrentActivity()));
1744
+ }
1745
+ return null;
1746
+ }));
1747
+ }
1748
+ for (int round = 0; round < rounds; round++) {
1749
+ // The later launch arrives while a reader may be claiming the earlier one.
1750
+ deliverFullScreen(controller, earlier.get(round));
1751
+ deliverFullScreen(controller, later.get(round));
1752
+ collect(delivered, getInitialNotification(reactContext.getCurrentActivity()));
1753
+ }
1754
+ delivering.set(false);
1755
+ for (Future<?> readerResult : readerResults) {
1756
+ readerResult.get(10, TimeUnit.SECONDS);
1757
+ }
1758
+ } finally {
1759
+ executor.shutdownNow();
1760
+ }
1761
+
1762
+ List<String> ids = new ArrayList<>();
1763
+ for (Bundle initialNotification : delivered) {
1764
+ ids.add(initialNotification.getBundle("notification").getString("id"));
1765
+ }
1766
+ for (int round = 0; round < rounds; round++) {
1767
+ // The earlier launch is replaced unless a read took it first.
1768
+ assertTrue(Collections.frequency(ids, "earlier-" + round) <= 1);
1769
+ assertEquals("later-" + round, 1, Collections.frequency(ids, "later-" + round));
1770
+ }
1771
+ }
1772
+
1773
+ @Test
1774
+ public void rt01_launchWhoseClaimCannotBePersisted_isReturnedOnceWritesWorkAgain()
1775
+ throws Exception {
1776
+ startReactInstance();
1777
+ ActivityController<ReactLaunchActivity> controller =
1778
+ launch(ReactLaunchActivity.class, launcherIntent());
1779
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1780
+ List<Bundle> delivered = new ArrayList<>();
1781
+
1782
+ withFailingPreferenceWrites(
1783
+ () -> {
1784
+ collect(delivered, jsGetInitialNotification());
1785
+ collect(delivered, jsGetInitialNotification());
1786
+ return null;
1787
+ });
1788
+ collect(delivered, jsGetInitialNotification());
1789
+ collect(delivered, jsGetInitialNotification());
1790
+
1791
+ assertDeliveredOnce(delivered, "full-screen");
1792
+ }
1793
+
1794
+ private void assertConcurrentReadsReturnEveryLaunchOnce(
1795
+ Class<? extends ReactLaunchActivity> hostClass) throws Exception {
1796
+ startReactInstance();
1797
+ ActivityController<? extends ReactLaunchActivity> controller =
1798
+ launch(hostClass, launcherIntent());
1799
+ int launches = 40;
1800
+ List<Intent[]> fullScreenFlows = new ArrayList<>();
1801
+ for (int i = 0; i < launches; i++) {
1802
+ fullScreenFlows.add(fullScreenFlow("full-screen-" + i, hostClass));
1803
+ }
1804
+
1805
+ int readers = 4;
1806
+ AtomicBoolean delivering = new AtomicBoolean(true);
1807
+ List<Bundle> delivered = Collections.synchronizedList(new ArrayList<>());
1808
+ ExecutorService executor = Executors.newFixedThreadPool(readers);
1809
+ try {
1810
+ List<Future<?>> readerResults = new ArrayList<>();
1811
+ for (int i = 0; i < readers; i++) {
1812
+ readerResults.add(
1813
+ executor.submit(
1814
+ () -> {
1815
+ while (delivering.get()) {
1816
+ collect(delivered, getInitialNotification(reactContext.getCurrentActivity()));
1817
+ }
1818
+ return null;
1819
+ }));
1820
+ }
1821
+ for (Intent[] fullScreenFlow : fullScreenFlows) {
1822
+ // Launches arrive on the main thread, then JS asks.
1823
+ deliverFullScreen(controller, fullScreenFlow);
1824
+ collect(delivered, getInitialNotification(reactContext.getCurrentActivity()));
1825
+ }
1826
+ delivering.set(false);
1827
+ for (Future<?> readerResult : readerResults) {
1828
+ readerResult.get(10, TimeUnit.SECONDS);
1829
+ }
1830
+ } finally {
1831
+ executor.shutdownNow();
1832
+ }
1833
+
1834
+ assertEquals(launches, delivered.size());
1835
+ List<String> ids = new ArrayList<>();
1836
+ for (Bundle initialNotification : delivered) {
1837
+ ids.add(initialNotification.getBundle("notification").getString("id"));
1838
+ }
1839
+ for (int i = 0; i < launches; i++) {
1840
+ assertEquals("full-screen-" + i, 1, Collections.frequency(ids, "full-screen-" + i));
1841
+ }
1842
+ assertNull(jsGetInitialNotification());
1843
+ assertTrue(reactContext.listenerExceptions.isEmpty());
1844
+ }
1845
+
1846
+ // --- React instance startup: launches recorded by FullScreenNotificationActivity (#88) ---
1847
+
1848
+ @Test
1849
+ @Config(sdk = {28, 31, 34, 35, 36, 37})
1850
+ public void startup_fullScreenDeliveredBeforeTheReactContextExists_isReturnedOnce()
1851
+ throws Exception {
1852
+ // The process was started for the Activity; its React instance has no ReactContext yet (W0).
1853
+ ActivityController<ReactLaunchActivity> controller =
1854
+ launch(ReactLaunchActivity.class, launcherIntent());
1855
+
1856
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1857
+ startReactInstance(controller.get());
1858
+
1859
+ assertEquals(Intent.ACTION_MAIN, controller.get().getIntent().getAction());
1860
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1861
+ assertNull(jsGetInitialNotification());
1862
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1863
+ }
1864
+
1865
+ @Test
1866
+ @Config(sdk = {28, 31, 34, 35, 36, 37})
1867
+ public void startup_fullScreenDeliveredBeforeTheEagerModulesExist_isReturnedOnce()
1868
+ throws Exception {
1869
+ ActivityController<ReactLaunchActivity> controller =
1870
+ launch(ReactLaunchActivity.class, launcherIntent());
1871
+ // The ReactContext exists; the instance has not created its eager TurboModules yet (W1).
1872
+ createReactContext(controller.get());
1873
+
1874
+ deliverFullScreen(controller, fullScreenFlow("full-screen"));
1875
+ initializeEagerTurboModules();
1876
+
1877
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1878
+ assertNull(jsGetInitialNotification());
1879
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1880
+ assertTrue(reactContext.listenerExceptions.isEmpty());
1881
+ }
1882
+
1883
+ @Test
1884
+ public void startup_restoredActivityGivenAFullScreenBeforeReact_returnsItOnceNeverAgain()
1885
+ throws Exception {
1886
+ // An incoming call for an app killed in the background with its task in Recents: the system
1887
+ // restores the Activity in a new process, then hands it the launch while React starts.
1888
+ startReactInstance();
1889
+ ActivityController<ReactLaunchActivity> controller =
1890
+ launch(ReactLaunchActivity.class, launcherIntent());
1891
+ Intent[] flow = fullScreenFlow("incoming-call");
1892
+ Bundle savedState = moveToBackground(controller);
1893
+
1894
+ simulateProcessDeath();
1895
+ ActivityController<ReactLaunchActivity> restored =
1896
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1897
+ deliverFullScreen(restored, flow);
1898
+ startReactInstance(restored.get());
1899
+
1900
+ assertInitialNotification(jsGetInitialNotification(), "incoming-call", null);
1901
+ assertNull(jsGetInitialNotification());
1902
+
1903
+ // Restored again in a later process, and given the same launch again.
1904
+ savedState = moveToBackground(restored);
1905
+ simulateProcessDeath();
1906
+ restored = restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
1907
+ deliverFullScreen(restored, flow);
1908
+ startReactInstance(restored.get());
1909
+ assertNull(jsGetInitialNotification());
1910
+ }
1911
+
1912
+ @Test
1913
+ public void startup_fullScreenCreatingTheActivity_isReturnedOnceWhicheverIsCreatedFirst()
1914
+ throws Exception {
1915
+ for (boolean receiverFirst : new boolean[] {false, true}) {
1916
+ String id = receiverFirst ? "receiver-first" : "activity-first";
1917
+ Intent[] flow = fullScreenFlow(id);
1918
+ ActivityController<ReactLaunchActivity> controller;
1919
+ if (receiverFirst) {
1920
+ runReceiver(fullScreenReceiverIntent(flow));
1921
+ controller = launch(ReactLaunchActivity.class, fullScreenLaunchIntent(flow));
1922
+ } else {
1923
+ controller = launch(ReactLaunchActivity.class, fullScreenLaunchIntent(flow));
1924
+ runReceiver(fullScreenReceiverIntent(flow));
1925
+ }
1926
+ startReactInstance(controller.get());
1927
+
1928
+ assertInitialNotification(jsGetInitialNotification(), id, null);
1929
+ assertNull(id, jsGetInitialNotification());
1930
+
1931
+ // Restored with its creation Intent, the launch Intent, in a later process.
1932
+ Bundle savedState = moveToBackground(controller);
1933
+ simulateProcessDeath();
1934
+ ActivityController<ReactLaunchActivity> restored =
1935
+ restoreController(ReactLaunchActivity.class, fullScreenLaunchIntent(flow), savedState);
1936
+ startReactInstance(restored.get());
1937
+ assertNull(id, jsGetInitialNotification());
1938
+ restored.pause().stop().destroy();
1939
+ }
1940
+ }
1941
+
1942
+ @Test
1943
+ public void startup_readBeforeTheReceiverIsCreated_doesNotConsumeTheLaunch() throws Exception {
1944
+ startReactInstance();
1945
+ ActivityController<ReactLaunchActivity> controller =
1946
+ launch(ReactLaunchActivity.class, launcherIntent());
1947
+ Intent[] flow = fullScreenFlow("full-screen");
1948
+
1949
+ // The Activity receives the launch Intent; JS asks before the capture receiver callback runs.
1950
+ controller.newIntent(fullScreenLaunchIntent(flow));
1951
+ assertNull(jsGetInitialNotification());
1952
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
1953
+
1954
+ runReceiver(fullScreenReceiverIntent(flow));
1955
+ assertInitialNotification(jsGetInitialNotification(), "full-screen", null);
1956
+ assertNull(jsGetInitialNotification());
1957
+ }
1958
+
1959
+ @Test
1960
+ public void notifeeModule_isCreatedWhenJsFirstUsesIt_andListensToNoActivityEvent() {
1961
+ ReactModuleInfo moduleInfo =
1962
+ new NotifeePackage()
1963
+ .getReactModuleInfoProvider()
1964
+ .getReactModuleInfos()
1965
+ .get(NotifeeApiModule.NAME);
1966
+ assertNotNull(moduleInfo);
1967
+ assertTrue(moduleInfo.isTurboModule());
1968
+ assertFalse(moduleInfo.needsEagerInit());
1969
+
1970
+ startReactInstance();
1971
+ assertNull("the React instance must not create the module", notifeeModule);
1972
+ assertNull(jsGetInitialNotification());
1973
+ assertNotNull(notifeeModule);
1974
+ assertTrue(reactContext.activityEventListeners.isEmpty());
1975
+ notifeeModule.invalidate();
1976
+ }
1977
+
1978
+ // --- Full-screen capture receiver ---
1979
+
1980
+ @Test
1981
+ public void fsReceiver_recordsTheLaunchWithoutTapEventsAndFinishes() throws Exception {
1982
+ NotificationEventRecorder events = recordTapEvents();
1983
+ Intent[] flow = fullScreenFlow("full-screen");
1984
+
1985
+ FullScreenNotificationActivity receiver =
1986
+ Robolectric.buildActivity(
1987
+ FullScreenNotificationActivity.class, fullScreenReceiverIntent(flow))
1988
+ .create()
1989
+ .get();
1990
+
1991
+ assertTrue(receiver.isFinishing());
1992
+ assertTrue(events.taps.isEmpty());
1993
+ assertNull(EventBus.getStickyEvent(InitialNotificationEvent.class));
1994
+ // Neither an Activity nor a React instance is needed to return it.
1995
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
1996
+ assertNull(getInitialNotification(null));
1997
+ assertEquals("FullScreenComponent", Notifee.getInstance().getMainComponent("DefaultComponent"));
1998
+ }
1999
+
2000
+ @Test
2001
+ public void fsReceiver_createdBeforeTheContextIsKnown_recordsTheLaunch() throws Exception {
2002
+ Intent[] flow = fullScreenFlow("full-screen");
2003
+ ContextHolder.setApplicationContext(null);
2004
+
2005
+ runReceiver(fullScreenReceiverIntent(flow));
2006
+
2007
+ assertNotNull(ContextHolder.getApplicationContext());
2008
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
2009
+ assertNull(getInitialNotification(null));
2010
+ }
2011
+
2012
+ @Test
2013
+ public void fsReceiver_forgedOrStaleLaunches_areNotRecordedAndKeepTheRecordedOne()
2014
+ throws Exception {
2015
+ NotificationEventRecorder events = recordTapEvents();
2016
+ Intent[] consumedFlow = fullScreenFlow("consumed");
2017
+ runReceiver(fullScreenReceiverIntent(consumedFlow));
2018
+ assertInitialNotification(getInitialNotification(null), "consumed", null);
2019
+ Intent[] flow = fullScreenFlow("full-screen");
2020
+ runReceiver(fullScreenReceiverIntent(flow));
2021
+ // Displayed, not launched yet.
2022
+ Intent[] otherFlow = fullScreenFlow("other");
2023
+ String tapPendingIntentId =
2024
+ launchFlow(display(pressNotification("tap")).contentIntent)[1].getStringExtra(
2025
+ PENDING_INTENT_ID_INTENT_KEY);
2026
+ Intent other = fullScreenReceiverIntent(otherFlow);
2027
+
2028
+ // The receiver is exported: any app can start it with any Intent.
2029
+ List<Intent> forged = new ArrayList<>();
2030
+ forged.add(
2031
+ new Intent(other).putExtra(PENDING_INTENT_ID_INTENT_KEY, UUID.randomUUID().toString()));
2032
+ forged.add(fullScreenReceiverIntent(consumedFlow));
2033
+ forged.add(new Intent(other).putExtra(PENDING_INTENT_ID_INTENT_KEY, tapPendingIntentId));
2034
+ forged.add(new Intent(other).putExtra(EVENT_TYPE_INTENT_KEY, NotificationEvent.TYPE_PRESS));
2035
+ forged.add(new Intent(other).putExtra(PENDING_INTENT_ID_INTENT_KEY, 88));
2036
+ forged.add(new Intent(other).putExtra("notification", "not-a-bundle"));
2037
+ Intent withoutId = new Intent(other);
2038
+ withoutId.removeExtra(PENDING_INTENT_ID_INTENT_KEY);
2039
+ forged.add(withoutId);
2040
+ Intent withoutNotification = new Intent(other);
2041
+ withoutNotification.removeExtra("notification");
2042
+ forged.add(withoutNotification);
2043
+ forged.add(new Intent(other).replaceExtras((Bundle) null));
2044
+ forged.add(new Intent(other).setAction(null));
2045
+ forged.add(new Intent(other).setAction(Intent.ACTION_VIEW));
2046
+ forged.add(
2047
+ new UnreadableExtrasIntent()
2048
+ .setClass(context, FullScreenNotificationActivity.class)
2049
+ .setAction(other.getAction()));
2050
+ for (Intent intent : forged) {
2051
+ runReceiver(intent);
2052
+ }
2053
+
2054
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
2055
+ assertNull(getInitialNotification(null));
2056
+ assertTrue(events.taps.isEmpty());
2057
+ // The other launch is still a valid one.
2058
+ runReceiver(fullScreenReceiverIntent(otherFlow));
2059
+ assertInitialNotification(getInitialNotification(null), "other", null);
2060
+ assertNull(getInitialNotification(null));
2061
+ }
2062
+
2063
+ @Test
2064
+ public void fsReceiver_launchDeliveredAgain_isReturnedOnce() throws Exception {
2065
+ Intent[] flow = fullScreenFlow("full-screen");
2066
+ runReceiver(fullScreenReceiverIntent(flow));
2067
+ // Started again on top of an existing receiver (SINGLE_TOP | CLEAR_TOP): onNewIntent().
2068
+ ActivityController<FullScreenNotificationActivity> receiver =
2069
+ Robolectric.buildActivity(FullScreenNotificationActivity.class, new Intent()).create();
2070
+ receiver.newIntent(fullScreenReceiverIntent(flow));
2071
+ assertTrue(receiver.get().isFinishing());
2072
+
2073
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
2074
+ assertNull(getInitialNotification(null));
2075
+
2076
+ // Once read, never again: in this process, or in a later one.
2077
+ runReceiver(fullScreenReceiverIntent(flow));
2078
+ receiver.newIntent(fullScreenReceiverIntent(flow));
2079
+ assertNull(getInitialNotification(null));
2080
+ simulateProcessDeath();
2081
+ runReceiver(fullScreenReceiverIntent(flow));
2082
+ assertNull(getInitialNotification(null));
2083
+ }
2084
+
2085
+ @Test
2086
+ public void fsReceiver_existingReceiverGivenTheLaunch_recordsIt() throws Exception {
2087
+ ActivityController<FullScreenNotificationActivity> receiver =
2088
+ Robolectric.buildActivity(FullScreenNotificationActivity.class, new Intent()).create();
2089
+
2090
+ receiver.newIntent(fullScreenReceiverIntent(fullScreenFlow("full-screen")));
2091
+
2092
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
2093
+ assertNull(getInitialNotification(null));
2094
+ }
2095
+
2096
+ @Test
2097
+ public void fsReceiver_launchRecordedWhileTheRecordedOneIsClaimed_isKept() throws Exception {
2098
+ Intent[] flowA = fullScreenFlow("full-screen-A");
2099
+ Intent receiverB = fullScreenReceiverIntent(fullScreenFlow("full-screen-B"));
2100
+ ActivityController<FullScreenNotificationActivity> receiver =
2101
+ Robolectric.buildActivity(
2102
+ FullScreenNotificationActivity.class, fullScreenReceiverIntent(flowA))
2103
+ .create();
2104
+ CountDownLatch claiming = new CountDownLatch(1);
2105
+ CountDownLatch recorded = new CountDownLatch(1);
2106
+ ExecutorService executor = Executors.newSingleThreadExecutor();
2107
+ try {
2108
+ // A read claims A; it is held right after the claim, while B is recorded.
2109
+ Future<Bundle> read =
2110
+ executor.submit(
2111
+ () -> {
2112
+ try (MockedStatic<InitialNotificationLedger> ledger =
2113
+ mockStatic(InitialNotificationLedger.class, Mockito.CALLS_REAL_METHODS)) {
2114
+ ledger
2115
+ .when(() -> InitialNotificationLedger.claimFullScreenLaunch(anyString()))
2116
+ .thenAnswer(
2117
+ invocation -> {
2118
+ Object claimed = invocation.callRealMethod();
2119
+ claiming.countDown();
2120
+ // Bounded: a recording that waits for this read cannot signal.
2121
+ recorded.await(500, TimeUnit.MILLISECONDS);
2122
+ return claimed;
2123
+ });
2124
+ return getInitialNotification(null);
2125
+ }
2126
+ });
2127
+ assertTrue(claiming.await(5, TimeUnit.SECONDS));
2128
+ receiver.newIntent(receiverB);
2129
+ recorded.countDown();
2130
+
2131
+ assertInitialNotification(read.get(5, TimeUnit.SECONDS), "full-screen-A", null);
2132
+ } finally {
2133
+ executor.shutdownNow();
2134
+ }
2135
+ assertInitialNotification(getInitialNotification(null), "full-screen-B", null);
2136
+ assertNull(getInitialNotification(null));
2137
+ }
2138
+
2139
+ @Test
2140
+ public void fsReceiver_launchReturnedThroughItsActivityIntentFirst_isNotRecorded()
2141
+ throws Exception {
2142
+ Intent[] flow = fullScreenFlow("full-screen", DefaultLaunchActivity.class);
2143
+ Activity activity = launch(DefaultLaunchActivity.class, fullScreenLaunchIntent(flow)).get();
2144
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
2145
+
2146
+ runReceiver(fullScreenReceiverIntent(flow));
2147
+
2148
+ assertNull(getInitialNotification(activity));
2149
+ assertNull(getInitialNotification(null));
2150
+ }
2151
+
2152
+ // --- Full-screen PendingIntent ---
2153
+
2154
+ @Test
2155
+ public void fsPendingIntent_startsReceiverThenConsumer() throws Exception {
2156
+ NotificationModel model = fullScreenNotification("full-screen", "FullScreenComponent");
2157
+ ShadowPendingIntent shadowPendingIntent = Shadows.shadowOf(display(model).fullScreenIntent);
2158
+
2159
+ assertTrue(shadowPendingIntent.isActivity());
2160
+ assertEquals((int) model.getHashCode(), shadowPendingIntent.getRequestCode());
2161
+ assertEquals(
2162
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE,
2163
+ shadowPendingIntent.getFlags());
2164
+ Intent[] saved = shadowPendingIntent.getSavedIntents();
2165
+ assertEquals(1, saved.length);
2166
+ Intent receiverIntent = saved[0];
2167
+ assertEquals(
2168
+ new ComponentName(context, FullScreenNotificationActivity.class),
2169
+ receiverIntent.getComponent());
2170
+ assertEquals(FULL_SCREEN_LAUNCH_ACTION, receiverIntent.getAction());
2171
+ assertEquals(
2172
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK,
2173
+ receiverIntent.getFlags());
2174
+ assertEquals(4, receiverIntent.getExtras().size());
2175
+ Intent destination =
2176
+ receiverIntent.getParcelableExtra(FullScreenNotificationActivity.LAUNCH_INTENT);
2177
+ assertNotNull(destination);
2178
+ assertEquals(
2179
+ new ComponentName(context, DefaultLaunchActivity.class), destination.getComponent());
2180
+ assertNull(destination.getAction());
2181
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK, destination.getFlags());
2182
+ assertNull("single payload source in the carrier", destination.getExtras());
2183
+ assertEquals("FullScreenComponent", receiverIntent.getStringExtra("mainComponent"));
2184
+ assertEquals("full-screen", receiverIntent.getBundleExtra("notification").getString("id"));
2185
+ assertNotNull(receiverIntent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY));
2186
+ runReceiver(receiverIntent);
2187
+ Intent launched =
2188
+ Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity();
2189
+ assertNotNull("the capture callback must launch its consumer itself", launched);
2190
+ assertEquals(destination.getComponent(), launched.getComponent());
2191
+ assertEquals(destination.getFlags(), launched.getFlags());
2192
+ assertEquals(3, launched.getExtras().size());
2193
+ assertFalse(launched.hasExtra(FullScreenNotificationActivity.LAUNCH_INTENT));
2194
+ assertEquals("full-screen", launched.getBundleExtra("notification").getString("id"));
2195
+ assertInitialNotification(getInitialNotification(null), "full-screen", null);
2196
+ }
2197
+
2198
+ @Test
2199
+ public void fsPendingIntent_launchActivityFlags_areKeptOnTheLaunchIntent() throws Exception {
2200
+ // launchActivityFlags indexes: 1 SINGLE_TOP, 2 NEW_TASK, 3 MULTIPLE_TASK, 4 CLEAR_TOP,
2201
+ // 16 CLEAR_TASK.
2202
+ assertFullScreenLaunchFlags("none", flagIndexes(), Intent.FLAG_ACTIVITY_NEW_TASK);
2203
+ assertFullScreenLaunchFlags(
2204
+ "single-top",
2205
+ flagIndexes(1),
2206
+ Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
2207
+ assertFullScreenLaunchFlags(
2208
+ "clear-top",
2209
+ flagIndexes(1, 4),
2210
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
2211
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
2212
+ | Intent.FLAG_ACTIVITY_NEW_TASK);
2213
+ assertFullScreenLaunchFlags(
2214
+ "clear-task",
2215
+ flagIndexes(2, 16),
2216
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
2217
+ assertFullScreenLaunchFlags(
2218
+ "multiple-task",
2219
+ flagIndexes(2, 3),
2220
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
2221
+ }
2222
+
2223
+ @Test
2224
+ public void fsPendingIntent_withoutMainComponent_isUnchanged() throws Exception {
2225
+ Bundle fullScreenAction = pressAction("default", "default", null);
2226
+ fullScreenAction.putIntegerArrayList("launchActivityFlags", flagIndexes(1));
2227
+ NotificationModel model = fullScreenNotification("legacy", fullScreenAction, new Bundle());
2228
+ ShadowPendingIntent shadowPendingIntent = Shadows.shadowOf(display(model).fullScreenIntent);
2229
+
2230
+ assertEquals(1, shadowPendingIntent.getSavedIntents().length);
2231
+ assertEquals((int) model.getHashCode(), shadowPendingIntent.getRequestCode());
2232
+ assertEquals(
2233
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE,
2234
+ shadowPendingIntent.getFlags());
2235
+ Intent launchIntent = shadowPendingIntent.getSavedIntent();
2236
+ assertEquals(
2237
+ new ComponentName(context, DefaultLaunchActivity.class), launchIntent.getComponent());
2238
+ assertEquals(Intent.FLAG_ACTIVITY_SINGLE_TOP, launchIntent.getFlags());
2239
+ assertNull(launchIntent.getExtras());
2240
+ }
2241
+
2242
+ @Test
2243
+ public void fsPendingIntent_customLaunchActivity_isCreatedAfterCapture() throws Exception {
2244
+ Intent[] flow = fullScreenFlow("custom", SetIntentLaunchActivity.class);
2245
+ assertEquals(
2246
+ new ComponentName(context, SetIntentLaunchActivity.class),
2247
+ fullScreenLaunchIntent(flow).getComponent());
2248
+
2249
+ ActivityController<SetIntentLaunchActivity> controller =
2250
+ launchFromFullScreen(SetIntentLaunchActivity.class, flow);
2251
+
2252
+ assertInitialNotification(getInitialNotification(controller.get()), "custom", null);
2253
+ assertNull(getInitialNotification(controller.get()));
2254
+ }
2255
+
2256
+ @Test
2257
+ public void fsPendingIntent_redisplayedNotification_updatesItsPendingIntent() throws Exception {
2258
+ android.app.NotificationManager notificationManager = nativeNotificationManager();
2259
+ NotificationModel first = fullScreenNotification("full-screen", "FullScreenComponent");
2260
+ NotificationManager.displayNotification(first, null).get(5, TimeUnit.SECONDS);
2261
+ PendingIntent firstPendingIntent =
2262
+ Shadows.shadowOf(notificationManager).getNotification(first.getHashCode()).fullScreenIntent;
2263
+ String firstId =
2264
+ Shadows.shadowOf(firstPendingIntent)
2265
+ .getSavedIntent()
2266
+ .getStringExtra(PENDING_INTENT_ID_INTENT_KEY);
2267
+
2268
+ // Same notification id: same request code and last Intent, so FLAG_UPDATE_CURRENT updates it.
2269
+ NotificationModel second = fullScreenNotification("full-screen", "FullScreenComponent");
2270
+ NotificationManager.displayNotification(second, null).get(5, TimeUnit.SECONDS);
2271
+ PendingIntent secondPendingIntent =
2272
+ Shadows.shadowOf(notificationManager)
2273
+ .getNotification(second.getHashCode())
2274
+ .fullScreenIntent;
2275
+
2276
+ assertSame(firstPendingIntent, secondPendingIntent);
2277
+ Intent primaryIntent = fullScreenLaunchIntent(secondPendingIntent);
2278
+ assertNotEquals(firstId, primaryIntent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY));
2279
+ Activity activity =
2280
+ launchFromFullScreen(DefaultLaunchActivity.class, fullScreenFlow(secondPendingIntent))
2281
+ .get();
2282
+ assertInitialNotification(getInitialNotification(activity), "full-screen", null);
2283
+ assertNull(getInitialNotification(activity));
2284
+ // The first display was never launched.
2285
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2286
+ }
2287
+
2288
+ @Test
2289
+ public void fsPendingIntent_isNotATapPendingIntentWithTheSameRequestCode() throws Exception {
2290
+ NotificationModel model = fullScreenNotification("full-screen", "FullScreenComponent");
2291
+ // A tap PendingIntent whose random request code is the full-screen notification's.
2292
+ Intent[] tapFlow = launchFlow(display(pressNotification("tap")).contentIntent);
2293
+ String tapPendingIntentId = tapFlow[1].getStringExtra(PENDING_INTENT_ID_INTENT_KEY);
2294
+ PendingIntent tap =
2295
+ PendingIntent.getActivities(
2296
+ context,
2297
+ model.getHashCode(),
2298
+ new Intent[] {new Intent(tapFlow[0]), new Intent(tapFlow[1])},
2299
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
2300
+
2301
+ Notification notification = display(model);
2302
+
2303
+ assertNotSame(tap, notification.fullScreenIntent);
2304
+ assertNotSame(notification.contentIntent, notification.fullScreenIntent);
2305
+ Intent tapReceiverIntent = launchFlow(tap)[1];
2306
+ assertNull(tapReceiverIntent.getAction());
2307
+ assertEquals(
2308
+ tapPendingIntentId, tapReceiverIntent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY));
2309
+ assertEquals(
2310
+ NotificationEvent.TYPE_PRESS, tapReceiverIntent.getIntExtra(EVENT_TYPE_INTENT_KEY, 0));
2311
+ assertEquals("tap", tapReceiverIntent.getBundleExtra("notification").getString("id"));
2312
+ }
2313
+
2314
+ @Test
2315
+ public void fsNotification_fullScreenLaunchAndTaps_areEachReturnedOnceWithTheirEvents()
2316
+ throws Exception {
2317
+ startReactInstance();
2318
+ ActivityController<ReactLaunchActivity> controller =
2319
+ launch(ReactLaunchActivity.class, launcherIntent());
2320
+ NotificationEventRecorder events = recordTapEvents();
2321
+ Bundle action = new Bundle();
2322
+ action.putString("title", "Answer");
2323
+ action.putBundle("pressAction", pressAction("answer", "default", null));
2324
+ ArrayList<Bundle> actions = new ArrayList<>();
2325
+ actions.add(action);
2326
+ Bundle androidBundle = new Bundle();
2327
+ androidBundle.putParcelableArrayList("actions", actions);
2328
+ Notification notification =
2329
+ display(
2330
+ fullScreenNotification(
2331
+ "call",
2332
+ pressAction("default", ReactLaunchActivity.class.getName(), "CallComponent"),
2333
+ androidBundle));
2334
+
2335
+ deliverFullScreen(controller, fullScreenFlow(notification.fullScreenIntent));
2336
+ assertInitialNotification(jsGetInitialNotification(), "call", null);
2337
+ assertTrue(events.taps.isEmpty());
2338
+
2339
+ controller.newIntent(tap(launchFlow(notification.contentIntent)));
2340
+ assertInitialNotification(jsGetInitialNotification(), "call", "default");
2341
+ controller.newIntent(tapWithInput(launchFlow(notification.actions[0].actionIntent), "Hi"));
2342
+ Bundle actionPress = jsGetInitialNotification();
2343
+ assertInitialNotification(actionPress, "call", "answer");
2344
+ assertEquals("Hi", actionPress.getString("input"));
2345
+ assertNull(jsGetInitialNotification());
2346
+ assertEquals(2, events.taps.size());
2347
+ assertEquals(NotificationEvent.TYPE_PRESS, events.taps.get(0).getType());
2348
+ assertEquals(NotificationEvent.TYPE_ACTION_PRESS, events.taps.get(1).getType());
2349
+ }
2350
+
2351
+ private void assertFullScreenLaunchFlags(
2352
+ String id, ArrayList<Integer> launchActivityFlags, int expectedFlags) throws Exception {
2353
+ Bundle fullScreenAction = pressAction("default", "default", "FullScreenComponent");
2354
+ fullScreenAction.putIntegerArrayList("launchActivityFlags", launchActivityFlags);
2355
+ Intent[] flow =
2356
+ fullScreenFlow(
2357
+ display(fullScreenNotification(id, fullScreenAction, new Bundle())).fullScreenIntent);
2358
+
2359
+ assertEquals(id, expectedFlags, fullScreenLaunchIntent(flow).getFlags());
2360
+ assertEquals(
2361
+ id,
2362
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK,
2363
+ fullScreenReceiverIntent(flow).getFlags());
2364
+ runReceiver(fullScreenReceiverIntent(flow));
2365
+ Intent actual = Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity();
2366
+ assertNotNull(actual);
2367
+ assertEquals(id, expectedFlags, actual.getFlags());
2368
+ assertEquals(fullScreenLaunchIntent(flow).getComponent(), actual.getComponent());
2369
+ assertEquals(3, actual.getExtras().size());
2370
+ }
2371
+
2372
+ private static ArrayList<Integer> flagIndexes(Integer... indexes) {
2373
+ ArrayList<Integer> flags = new ArrayList<>();
2374
+ Collections.addAll(flags, indexes);
2375
+ return flags;
2376
+ }
2377
+
2378
+ // --- Strict latest-wins regression witnesses ---
2379
+
2380
+ /** RR-1a: latest-wins, read once at startup, then process death and restore. */
2381
+ @Test
2382
+ public void rr1a_supersededCreationLaunch_afterProcessDeathRestore() throws Exception {
2383
+ // Call C cold-starts the Activity; call D reaches it before React exists (W0).
2384
+ ActivityController<ReactLaunchActivity> controller =
2385
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("call-C"));
2386
+ deliverFullScreen(controller, fullScreenFlow("call-D"));
2387
+ startReactInstance(controller.get());
2388
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2389
+
2390
+ // Hours later: backgrounded, process killed, task restored from Recents with its creation
2391
+ // Intent (call C).
2392
+ Bundle savedState = moveToBackground(controller);
2393
+ simulateProcessDeath();
2394
+ ActivityController<ReactLaunchActivity> restored =
2395
+ restoreController(
2396
+ ReactLaunchActivity.class, new Intent(controller.get().getIntent()), savedState);
2397
+ startReactInstance(restored.get());
2398
+ Bundle afterRestore = jsGetInitialNotification();
2399
+ assertNull("superseded call-C must not surface after restore", afterRestore);
2400
+ }
2401
+
2402
+ /** RR-1b: the same within one process, on a second read (remount, JS reload, recreation). */
2403
+ @Test
2404
+ public void rr1b_supersededCreationLaunch_onSecondReadSameProcess() throws Exception {
2405
+ ActivityController<ReactLaunchActivity> controller =
2406
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("call-C"));
2407
+ deliverFullScreen(controller, fullScreenFlow("call-D"));
2408
+ startReactInstance(controller.get());
2409
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2410
+ Bundle second = jsGetInitialNotification();
2411
+ assertNull("superseded call-C must not be returned after call-D", second);
2412
+ }
2413
+
2414
+ /** RR-1c control: the same two launches, both received while the Activity already exists. */
2415
+ @Test
2416
+ public void rr1c_control_supersededLaunchOnExistingActivity_isNotReturned() throws Exception {
2417
+ startReactInstance();
2418
+ ActivityController<ReactLaunchActivity> controller =
2419
+ launch(ReactLaunchActivity.class, launcherIntent());
2420
+ deliverFullScreen(controller, fullScreenFlow("call-C"));
2421
+ deliverFullScreen(controller, fullScreenFlow("call-D"));
2422
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2423
+ assertNull(jsGetInitialNotification());
2424
+ Bundle savedState = moveToBackground(controller);
2425
+ simulateProcessDeath();
2426
+ ActivityController<ReactLaunchActivity> restored =
2427
+ restoreController(ReactLaunchActivity.class, launcherIntent(), savedState);
2428
+ startReactInstance(restored.get());
2429
+ assertNull(jsGetInitialNotification());
2430
+ }
2431
+
2432
+ @Test
2433
+ @Config(sdk = {28, 34, 37})
2434
+ public void rr01_replacedLaunchIsRetiredBeforeReadingTheReplacement() throws Exception {
2435
+ Intent[] flowC = fullScreenFlow("call-C");
2436
+ ActivityController<ReactLaunchActivity> controller =
2437
+ launchFromFullScreen(ReactLaunchActivity.class, flowC);
2438
+ deliverFullScreen(controller, fullScreenFlow("call-D"));
2439
+ Bundle savedState = moveToBackground(controller);
2440
+ Intent creationIntent = new Intent(controller.get().getIntent());
2441
+
2442
+ // The replacement's in-memory payload may be lost here. The retired C must stay retired.
2443
+ simulateProcessDeath();
2444
+ ActivityController<ReactLaunchActivity> restored =
2445
+ restoreController(ReactLaunchActivity.class, creationIntent, savedState);
2446
+ startReactInstance(restored.get());
2447
+ assertNull(jsGetInitialNotification());
2448
+ runReceiver(fullScreenReceiverIntent(flowC));
2449
+ assertNull(jsGetInitialNotification());
2450
+ }
2451
+
2452
+ @Test
2453
+ public void rr01_replacementAfterProcessDeath_retiresTheRestoredCreationLaunch()
2454
+ throws Exception {
2455
+ Intent[] flowC = fullScreenFlow("call-C");
2456
+ ActivityController<ReactLaunchActivity> controller =
2457
+ launchFromFullScreen(ReactLaunchActivity.class, flowC);
2458
+ Bundle savedState = moveToBackground(controller);
2459
+ Intent creationIntent = new Intent(controller.get().getIntent());
2460
+ simulateProcessDeath();
2461
+ ActivityController<ReactLaunchActivity> restored =
2462
+ restoreController(ReactLaunchActivity.class, creationIntent, savedState);
2463
+
2464
+ // No in-memory capture of C survives. D still has to replace it permanently.
2465
+ deliverFullScreen(restored, fullScreenFlow("call-D"));
2466
+ startReactInstance(restored.get());
2467
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2468
+ assertNull(jsGetInitialNotification());
2469
+ savedState = moveToBackground(restored);
2470
+ simulateProcessDeath();
2471
+ restored = restoreController(ReactLaunchActivity.class, creationIntent, savedState);
2472
+ startReactInstance(restored.get());
2473
+ assertNull(jsGetInitialNotification());
2474
+ runReceiver(fullScreenReceiverIntent(flowC));
2475
+ assertNull(jsGetInitialNotification());
2476
+ }
2477
+
2478
+ @Test
2479
+ public void rr01_replacementPreservesNotificationsThatHaveNotLaunched() throws Exception {
2480
+ ActivityController<ReactLaunchActivity> controller =
2481
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("call-C"));
2482
+ Intent[] unlaunched = fullScreenFlow("not-launched-yet");
2483
+ Intent[] replacement = fullScreenFlow("call-D");
2484
+ deliverFullScreen(controller, replacement);
2485
+ // Repeated delivery of D must not retire D itself.
2486
+ deliverFullScreen(controller, replacement);
2487
+ startReactInstance(controller.get());
2488
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2489
+ assertNull(jsGetInitialNotification());
2490
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2491
+ deliverFullScreen(controller, unlaunched);
2492
+ assertInitialNotification(jsGetInitialNotification(), "not-launched-yet", null);
2493
+ assertNull(jsGetInitialNotification());
2494
+ assertEquals(0, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2495
+ }
2496
+
2497
+ @Test
2498
+ public void rr01_failedReplacementIsRetriedAtReadWithoutRedelivery() throws Exception {
2499
+ Intent[] flowC = fullScreenFlow("call-C");
2500
+ ActivityController<ReactLaunchActivity> controller =
2501
+ launchFromFullScreen(ReactLaunchActivity.class, flowC);
2502
+ Intent[] replacement = fullScreenFlow("call-D");
2503
+ withFailingPreferenceWrites(
2504
+ () -> {
2505
+ runReceiver(fullScreenReceiverIntent(replacement));
2506
+ return null;
2507
+ });
2508
+ // C is retired in memory; D's payload is kept and its next claim persists both changes.
2509
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2510
+ startReactInstance(controller.get());
2511
+ assertInitialNotification(jsGetInitialNotification(), "call-D", null);
2512
+ assertNull(jsGetInitialNotification());
2513
+ Bundle state = moveToBackground(controller);
2514
+ simulateProcessDeath();
2515
+ ActivityController<ReactLaunchActivity> restored =
2516
+ restoreController(
2517
+ ReactLaunchActivity.class, new Intent(controller.get().getIntent()), state);
2518
+ startReactInstance(restored.get());
2519
+ assertNull(jsGetInitialNotification());
2520
+ runReceiver(fullScreenReceiverIntent(flowC));
2521
+ assertNull(jsGetInitialNotification());
2522
+ }
2523
+
2524
+ // --- Independent review regression witnesses ---
2525
+ @Test
2526
+ public void independent_failedReplacementReturnsLatestAfterStorageRecoveryWithoutRedelivery()
2527
+ throws Exception {
2528
+ ActivityController<ReactLaunchActivity> host =
2529
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("call-C"));
2530
+ Intent[] d = fullScreenFlow("call-D");
2531
+ withFailingPreferenceWrites(
2532
+ () -> {
2533
+ deliverFullScreen(host, d);
2534
+ return null;
2535
+ });
2536
+ startReactInstance(host.get());
2537
+ Bundle first = jsGetInitialNotification();
2538
+ assertInitialNotification(first, "call-D", null);
2539
+ assertNull(jsGetInitialNotification());
2540
+ }
2541
+
2542
+ @Test
2543
+ public void independent_failedReplacementCanReturnDThenResurrectCThroughActivityFallback()
2544
+ throws Exception {
2545
+ Intent[] c = fullScreenFlow("call-C", SetIntentLaunchActivity.class);
2546
+ ActivityController<SetIntentLaunchActivity> host =
2547
+ launchFromFullScreen(SetIntentLaunchActivity.class, c);
2548
+ Bundle state = moveToBackground(host);
2549
+ Intent creation = new Intent(host.get().getIntent());
2550
+ simulateProcessDeath();
2551
+ host = restoreController(SetIntentLaunchActivity.class, creation, state);
2552
+ Intent[] d = fullScreenFlow("call-D", SetIntentLaunchActivity.class);
2553
+ final ActivityController<SetIntentLaunchActivity> restored = host;
2554
+ withFailingPreferenceWrites(
2555
+ () -> {
2556
+ deliverFullScreen(restored, d);
2557
+ return null;
2558
+ });
2559
+ Bundle first = getInitialNotification(host.get());
2560
+ assertInitialNotification(first, "call-D", null);
2561
+ state = moveToBackground(host);
2562
+ simulateProcessDeath();
2563
+ Activity afterDeath = restore(SetIntentLaunchActivity.class, creation, state);
2564
+ Bundle replay = getInitialNotification(afterDeath);
2565
+ assertNull("C was superseded by valid D before D was returned", replay);
2566
+ }
2567
+
2568
+ @Test
2569
+ public void independent_failedTapClaimMustNotReplayAfterProcessDeath() throws Exception {
2570
+ Intent[] flow = launchFlow(display(pressNotification("tap-A")).contentIntent);
2571
+ ActivityController<DefaultLaunchActivity> host = launch(DefaultLaunchActivity.class, tap(flow));
2572
+ assertTrue(
2573
+ context
2574
+ .getSharedPreferences("app.notifee.core.initial_notification", Context.MODE_PRIVATE)
2575
+ .edit()
2576
+ .putLong("independent-witness-flush", 1)
2577
+ .commit());
2578
+ List<Bundle> delivered = new ArrayList<>();
2579
+ withFailingPreferenceWrites(
2580
+ () -> {
2581
+ collect(delivered, getInitialNotification(host.get()));
2582
+ return null;
2583
+ });
2584
+ Bundle state = moveToBackground(host);
2585
+ simulateProcessDeath();
2586
+ Activity afterDeath = restore(DefaultLaunchActivity.class, new Intent(flow[0]), state);
2587
+ collect(delivered, getInitialNotification(afterDeath));
2588
+ assertDeliveredOnce(delivered, "tap-A");
2589
+ }
2590
+
2591
+ @Test
2592
+ public void independent_nominalPersistentReplacementAcrossTwoProcessDeaths() throws Exception {
2593
+ Intent[] c = fullScreenFlow("call-C");
2594
+ ActivityController<ReactLaunchActivity> host =
2595
+ launchFromFullScreen(ReactLaunchActivity.class, c);
2596
+ Intent creation = new Intent(host.get().getIntent());
2597
+ Bundle state = moveToBackground(host);
2598
+ simulateProcessDeath();
2599
+ host = restoreController(ReactLaunchActivity.class, creation, state);
2600
+ deliverFullScreen(host, fullScreenFlow("call-D"));
2601
+ state = moveToBackground(host);
2602
+ simulateProcessDeath();
2603
+ host = restoreController(ReactLaunchActivity.class, creation, state);
2604
+ assertNull(getInitialNotification(host.get()));
2605
+ runReceiver(fullScreenReceiverIntent(c));
2606
+ assertNull(getInitialNotification(host.get()));
2607
+ }
2608
+
2609
+ @Test
2610
+ public void independent_failedRecoveredTapIntentClaimMustNotReplayAfterProcessDeath()
2611
+ throws Exception {
2612
+ Intent[] flow = launchFlow(display(pressNotification("tap-A")).contentIntent);
2613
+ ActivityController<DefaultLaunchActivity> host = launch(DefaultLaunchActivity.class, tap(flow));
2614
+ assertTrue(
2615
+ context
2616
+ .getSharedPreferences("app.notifee.core.initial_notification", Context.MODE_PRIVATE)
2617
+ .edit()
2618
+ .putLong("independent-witness-flush", 1)
2619
+ .commit());
2620
+ Bundle state = moveToBackground(host);
2621
+ simulateProcessDeath();
2622
+ final ActivityController<DefaultLaunchActivity> restored =
2623
+ restoreController(DefaultLaunchActivity.class, new Intent(flow[0]), state);
2624
+ List<Bundle> delivered = new ArrayList<>();
2625
+ withFailingPreferenceWrites(
2626
+ () -> {
2627
+ collect(delivered, getInitialNotification(restored.get()));
2628
+ return null;
2629
+ });
2630
+ state = moveToBackground(restored);
2631
+ simulateProcessDeath();
2632
+ collect(
2633
+ delivered,
2634
+ getInitialNotification(restore(DefaultLaunchActivity.class, new Intent(flow[0]), state)));
2635
+ assertDeliveredOnce(delivered, "tap-A");
2636
+ }
2637
+
2638
+ @Test
2639
+ public void storage_failedTapClaim_keepsActionInputUntilTheReadCanBePersisted() throws Exception {
2640
+ Intent[] flow =
2641
+ launchFlow(
2642
+ display(actionNotification("reply", pressAction("answer", "default", null)))
2643
+ .actions[0]
2644
+ .actionIntent);
2645
+ ActivityController<DefaultLaunchActivity> host =
2646
+ launch(DefaultLaunchActivity.class, tapWithInput(flow, "Keep this reply"));
2647
+ withFailingPreferenceWrites(
2648
+ () -> {
2649
+ assertNull(getInitialNotification(host.get()));
2650
+ assertNull(getInitialNotification(host.get()));
2651
+ return null;
2652
+ });
2653
+ Bundle result = getInitialNotification(host.get());
2654
+ assertInitialNotification(result, "reply", "answer");
2655
+ assertEquals("Keep this reply", result.getString("input"));
2656
+ assertNull(getInitialNotification(host.get()));
2657
+ Bundle state = moveToBackground(host);
2658
+ simulateProcessDeath();
2659
+ assertNull(
2660
+ getInitialNotification(restore(DefaultLaunchActivity.class, new Intent(flow[0]), state)));
2661
+ }
2662
+
2663
+ @Test
2664
+ public void storage_unreadableTapClaim_keepsTheStickyPayloadForRecovery() throws Exception {
2665
+ Intent[] flow = launchFlow(display(pressNotification("tap")).contentIntent);
2666
+ Activity host = launch(DefaultLaunchActivity.class, tap(flow)).get();
2667
+ SharedPreferences preferences =
2668
+ context.getSharedPreferences("app.notifee.core.initial_notification", Context.MODE_PRIVATE);
2669
+ assertTrue(preferences.edit().putString("claimed_tap_sequence", "unreadable").commit());
2670
+ assertNull(getInitialNotification(host));
2671
+ assertTrue(preferences.edit().putLong("claimed_tap_sequence", 0).commit());
2672
+ assertInitialNotification(getInitialNotification(host), "tap", "default");
2673
+ assertNull(getInitialNotification(host));
2674
+ }
2675
+
2676
+ @Test
2677
+ public void storage_aNewTapBetweenClaimAndStickyRemoval_isPreserved() throws Exception {
2678
+ Intent[] a = launchFlow(display(pressNotification("A")).contentIntent);
2679
+ Intent[] b = launchFlow(display(pressNotification("B")).contentIntent);
2680
+ Activity host = launch(DefaultLaunchActivity.class, tap(a)).get();
2681
+ try (MockedStatic<InitialNotificationLedger> ledger =
2682
+ mockStatic(InitialNotificationLedger.class, Mockito.CALLS_REAL_METHODS)) {
2683
+ ledger
2684
+ .when(() -> InitialNotificationLedger.claimTap(org.mockito.ArgumentMatchers.anyLong()))
2685
+ .thenAnswer(
2686
+ invocation -> {
2687
+ Object result = invocation.callRealMethod();
2688
+ runReceiver(b);
2689
+ return result;
2690
+ });
2691
+ assertInitialNotification(getInitialNotification(host), "A", "default");
2692
+ }
2693
+ assertInitialNotification(getInitialNotification(host), "B", "default");
2694
+ assertNull(getInitialNotification(host));
2695
+ }
2696
+
2697
+ @Test
2698
+ public void storage_failedFullScreenReplacements_keepOnlyLatestAndRetryAtRead() throws Exception {
2699
+ ActivityController<ReactLaunchActivity> host =
2700
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("C"));
2701
+ Intent[] d = fullScreenFlow("D");
2702
+ Intent[] e = fullScreenFlow("E");
2703
+ withFailingPreferenceWrites(
2704
+ () -> {
2705
+ deliverFullScreen(host, d);
2706
+ assertNull(getInitialNotification(host.get()));
2707
+ deliverFullScreen(host, e);
2708
+ assertNull(getInitialNotification(host.get()));
2709
+ return null;
2710
+ });
2711
+ assertInitialNotification(getInitialNotification(host.get()), "E", null);
2712
+ assertNull(getInitialNotification(host.get()));
2713
+ Bundle state = moveToBackground(host);
2714
+ simulateProcessDeath();
2715
+ assertNull(
2716
+ getInitialNotification(
2717
+ restore(ReactLaunchActivity.class, new Intent(host.get().getIntent()), state)));
2718
+ runReceiver(fullScreenReceiverIntent(d));
2719
+ assertNull(getInitialNotification(null));
2720
+ }
2721
+
2722
+ @Test
2723
+ public void storage_failedReplacementLostWithProcess_preservesTheDurableCreationLaunch()
2724
+ throws Exception {
2725
+ Intent[] c = fullScreenFlow("call-C");
2726
+ ActivityController<ReactLaunchActivity> host =
2727
+ launchFromFullScreen(ReactLaunchActivity.class, c);
2728
+ Intent creation = new Intent(host.get().getIntent());
2729
+ Intent[] d = fullScreenFlow("call-D");
2730
+ String durableBefore = initialNotificationLedgerOnDisk();
2731
+ try (MockedStatic<Logger> logger = mockStatic(Logger.class, Mockito.CALLS_REAL_METHODS)) {
2732
+ withFailingPreferenceWrites(
2733
+ () -> {
2734
+ deliverFullScreen(host, d);
2735
+ return null;
2736
+ });
2737
+ logger.verify(
2738
+ () ->
2739
+ Logger.w(
2740
+ "InitialNotificationLedger", "Failed to persist full-screen launch replacement"));
2741
+ }
2742
+ assertEquals(durableBefore, initialNotificationLedgerOnDisk());
2743
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2744
+ Bundle state = moveToBackground(host);
2745
+ simulateProcessDeath();
2746
+ assertEquals(durableBefore, initialNotificationLedgerOnDisk());
2747
+ Activity restored = restore(ReactLaunchActivity.class, creation, state);
2748
+ // PO: with no durable evidence of D, preserve the legitimate unread creation launch.
2749
+ assertInitialNotification(getInitialNotification(restored), "call-C", null);
2750
+ assertNull(getInitialNotification(restored));
2751
+ }
2752
+
2753
+ @Test
2754
+ public void storage_multipleUnpersistedReplacementsLostWithProcess_preserveDurableRecovery()
2755
+ throws Exception {
2756
+ Intent[] c = fullScreenFlow("call-C");
2757
+ ActivityController<ReactLaunchActivity> host =
2758
+ launchFromFullScreen(ReactLaunchActivity.class, c);
2759
+ Intent creation = new Intent(host.get().getIntent());
2760
+ Intent[] d = fullScreenFlow("call-D");
2761
+ Intent[] e = fullScreenFlow("call-E");
2762
+ String durableBefore = initialNotificationLedgerOnDisk();
2763
+ try (MockedStatic<Logger> logger = mockStatic(Logger.class, Mockito.CALLS_REAL_METHODS)) {
2764
+ withFailingPreferenceWrites(
2765
+ () -> {
2766
+ deliverFullScreen(host, d);
2767
+ deliverFullScreen(host, e);
2768
+ return null;
2769
+ });
2770
+ logger.verify(
2771
+ () ->
2772
+ Logger.w(
2773
+ "InitialNotificationLedger", "Failed to persist full-screen launch replacement"),
2774
+ Mockito.times(2));
2775
+ }
2776
+ assertEquals(durableBefore, initialNotificationLedgerOnDisk());
2777
+ assertEquals(1, InitialNotificationLedger.unclaimedFullScreenLaunchCountForTesting());
2778
+ Bundle state = moveToBackground(host);
2779
+ simulateProcessDeath();
2780
+ assertEquals(durableBefore, initialNotificationLedgerOnDisk());
2781
+ Activity restored = restore(ReactLaunchActivity.class, creation, state);
2782
+ assertInitialNotification(getInitialNotification(restored), "call-C", null);
2783
+ assertNull(getInitialNotification(restored));
2784
+ }
2785
+
2786
+ @Test
2787
+ public void storage_successfulWriteAfterFailedReplacement_preventsResurrectionWithoutARead()
2788
+ throws Exception {
2789
+ ActivityController<ReactLaunchActivity> host =
2790
+ launchFromFullScreen(ReactLaunchActivity.class, fullScreenFlow("call-C"));
2791
+ Intent creation = new Intent(host.get().getIntent());
2792
+ Intent[] d = fullScreenFlow("call-D");
2793
+ withFailingPreferenceWrites(
2794
+ () -> {
2795
+ deliverFullScreen(host, d);
2796
+ return null;
2797
+ });
2798
+ // SharedPreferences commits the full in-memory map. Any successful write supplies the proof
2799
+ // that excludes the PO exception, even though D has never been read.
2800
+ assertTrue(
2801
+ context
2802
+ .getSharedPreferences("app.notifee.core.initial_notification", Context.MODE_PRIVATE)
2803
+ .edit()
2804
+ .putBoolean("successful-write-witness", true)
2805
+ .commit());
2806
+ Bundle state = moveToBackground(host);
2807
+ simulateProcessDeath();
2808
+ Activity restored = restore(ReactLaunchActivity.class, creation, state);
2809
+ assertNull(getInitialNotification(restored));
2810
+ assertNull(getInitialNotification(restored));
2811
+ }
2812
+
2813
+ private String initialNotificationLedgerOnDisk() throws Exception {
2814
+ return new String(
2815
+ java.nio.file.Files.readAllBytes(
2816
+ new File(context.getDataDir(), "shared_prefs/app.notifee.core.initial_notification.xml")
2817
+ .toPath()),
2818
+ java.nio.charset.StandardCharsets.UTF_8);
2819
+ }
2820
+
2821
+ @Test
2822
+ @Config(sdk = {26, 27, 28, 31, 33, 36, 37})
2823
+ public void rr02_fullScreenHasOnePrivateNoDisplayCaptureTarget() throws Exception {
2824
+ PendingIntent pending =
2825
+ display(fullScreenNotification("lock-screen", "FullScreenComponent")).fullScreenIntent;
2826
+ Intent[] saved = Shadows.shadowOf(pending).getSavedIntents();
2827
+ assertEquals(1, saved.length);
2828
+ assertEquals(
2829
+ new ComponentName(context, FullScreenNotificationActivity.class), saved[0].getComponent());
2830
+ assertEquals(
2831
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK, saved[0].getFlags());
2832
+ // Unit fixtures register synthetic Activities without a merged manifest. Inspect the actual
2833
+ // library declaration here; APK/PackageManager binding is independently checked at runtime.
2834
+ org.w3c.dom.NodeList activities =
2835
+ javax.xml.parsers.DocumentBuilderFactory.newInstance()
2836
+ .newDocumentBuilder()
2837
+ .parse(new File("src/main/AndroidManifest.xml"))
2838
+ .getElementsByTagName("activity");
2839
+ org.w3c.dom.Element declaration = null;
2840
+ for (int i = 0; i < activities.getLength(); i++) {
2841
+ org.w3c.dom.Element activity = (org.w3c.dom.Element) activities.item(i);
2842
+ if (FullScreenNotificationActivity.class
2843
+ .getName()
2844
+ .equals(activity.getAttribute("android:name"))) {
2845
+ declaration = activity;
2846
+ }
2847
+ }
2848
+ assertNotNull(declaration);
2849
+ assertEquals("false", declaration.getAttribute("android:exported"));
2850
+ assertEquals("@android:style/Theme.NoDisplay", declaration.getAttribute("android:theme"));
2851
+ // A noHistory task may be cancelled before its queued onCreate can capture the launch.
2852
+ assertEquals("false", declaration.getAttribute("android:noHistory"));
2853
+ assertEquals("true", declaration.getAttribute("android:excludeFromRecents"));
2854
+ assertEquals("", declaration.getAttribute("android:taskAffinity"));
2855
+ assertEquals("true", declaration.getAttribute("android:showWhenLocked"));
2856
+ assertEquals("true", declaration.getAttribute("android:turnScreenOn"));
2857
+ }
2858
+
2859
+ @Test
2860
+ @Config(sdk = {26, 28, 34, 37})
2861
+ public void rr02_coldFullScreenRestoresMainComponentBeforeConsumerCreation() throws Exception {
2862
+ Intent[] flow =
2863
+ fullScreenFlow(
2864
+ display(
2865
+ fullScreenNotification(
2866
+ "cold-component",
2867
+ "IncomingCall",
2868
+ ComponentCheckingLaunchActivity.class.getName()))
2869
+ .fullScreenIntent);
2870
+ simulateProcessDeath();
2871
+ ActivityController<ComponentCheckingLaunchActivity> controller =
2872
+ launchFromFullScreen(ComponentCheckingLaunchActivity.class, flow);
2873
+ assertEquals("IncomingCall", controller.get().selectedMainComponent);
2874
+ assertNull("capture must not require a Notifee TurboModule", notifeeModule);
2875
+ assertInitialNotification(getInitialNotification(controller.get()), "cold-component", null);
2876
+ assertNull(getInitialNotification(controller.get()));
2877
+ }
2878
+
2879
+ @Test
2880
+ public void rr02_onlyAValidUnreadFullScreenRestoresMainComponent() throws Exception {
2881
+ Intent[] flow = fullScreenFlow("real");
2882
+ EventBus.removeStickEvent(MainComponentEvent.class);
2883
+ runReceiver(fullScreenReceiverIntent(flow));
2884
+ assertEquals("FullScreenComponent", NotifeeApiModule.getMainComponent("DefaultComponent"));
2885
+ assertInitialNotification(getInitialNotification(null), "real", null);
2886
+ runReceiver(fullScreenReceiverIntent(flow));
2887
+ assertEquals("DefaultComponent", NotifeeApiModule.getMainComponent("DefaultComponent"));
2888
+ Intent forged = fullScreenReceiverIntent(fullScreenFlow("forged"));
2889
+ EventBus.removeStickEvent(MainComponentEvent.class);
2890
+ forged.putExtra(PENDING_INTENT_ID_INTENT_KEY, UUID.randomUUID().toString());
2891
+ forged.putExtra("mainComponent", "ForgedComponent");
2892
+ runReceiver(forged);
2893
+ assertEquals("DefaultComponent", NotifeeApiModule.getMainComponent("DefaultComponent"));
2894
+ }
2895
+
2896
+ @Test
2897
+ public void invalidCapture_malformedNotificationCannotRetireUnreadLaunch() throws Exception {
2898
+ for (int variant = 0; variant < 5; variant++) {
2899
+ Intent[] c = fullScreenFlow("valid-C-" + variant);
2900
+ Intent[] d = fullScreenFlow("valid-D-" + variant);
2901
+ runReceiver(fullScreenReceiverIntent(c));
2902
+ Intent malformed = new Intent(fullScreenReceiverIntent(d));
2903
+ Bundle notification = new Bundle();
2904
+ if (variant == 1) notification.putInt("id", 88);
2905
+ if (variant == 2) notification.putString("id", null);
2906
+ if (variant == 3) notification.putString("id", "");
2907
+ if (variant == 4) notification.putBundle("id", new Bundle());
2908
+ malformed.putExtra("notification", notification);
2909
+ malformed.putExtra("mainComponent", "InvalidComponent");
2910
+ Object component = EventBus.getStickyEvent(MainComponentEvent.class);
2911
+ runReceiver(malformed);
2912
+ assertSame(component, EventBus.getStickyEvent(MainComponentEvent.class));
2913
+ assertInitialNotification(getInitialNotification(null), "valid-C-" + variant, null);
2914
+ assertNull(getInitialNotification(null));
2915
+ // Rejecting the corrupt copy must also preserve the genuine, issued D.
2916
+ runReceiver(fullScreenReceiverIntent(d));
2917
+ assertInitialNotification(getInitialNotification(null), "valid-D-" + variant, null);
2918
+ assertNull(getInitialNotification(null));
2919
+ }
2920
+ }
2921
+
2922
+ @Test
2923
+ @Config(sdk = {26, 28, 33, 37})
2924
+ public void runtime_warmCaptureMustBeTheSolePendingIntentTarget() throws Exception {
2925
+ PendingIntent pending =
2926
+ display(fullScreenNotification("warm-capture", "IncomingCall")).fullScreenIntent;
2927
+ Intent[] saved = Shadows.shadowOf(pending).getSavedIntents();
2928
+ // A non-top member of getActivities can remain uncreated while the consumer is visible.
2929
+ assertEquals("capture must execute before it launches any consumer", 1, saved.length);
2930
+ assertEquals(
2931
+ "app.notifee.core.FullScreenNotificationActivity", saved[0].getComponent().getClassName());
2932
+ assertEquals(FULL_SCREEN_LAUNCH_ACTION, saved[0].getAction());
2933
+ }
2934
+
2935
+ @Test
2936
+ public void fsPendingIntent_consumerIdentityIsPreservedAndRoutingStaysPrivate() throws Exception {
2937
+ NotificationModel a =
2938
+ fullScreenNotification("same-id", "ComponentA", DefaultLaunchActivity.class.getName());
2939
+ NotificationManager.displayNotification(a, null).get(5, TimeUnit.SECONDS);
2940
+ PendingIntent pendingA =
2941
+ Shadows.shadowOf(nativeNotificationManager())
2942
+ .getNotification(a.getHashCode())
2943
+ .fullScreenIntent;
2944
+ NotificationModel b =
2945
+ fullScreenNotification("same-id", "ComponentB", ReactLaunchActivity.class.getName());
2946
+ NotificationManager.displayNotification(b, null).get(5, TimeUnit.SECONDS);
2947
+ PendingIntent pendingB =
2948
+ Shadows.shadowOf(nativeNotificationManager())
2949
+ .getNotification(b.getHashCode())
2950
+ .fullScreenIntent;
2951
+ assertNotSame(
2952
+ "different consumer components must remain different identities", pendingA, pendingB);
2953
+ Bundle action = pressAction("default", ReactLaunchActivity.class.getName(), "UpdatedComponent");
2954
+ action.putIntegerArrayList("launchActivityFlags", flagIndexes(2, 16));
2955
+ NotificationModel updated = fullScreenNotification("same-id", action, new Bundle());
2956
+ NotificationManager.displayNotification(updated, null).get(5, TimeUnit.SECONDS);
2957
+ PendingIntent current =
2958
+ Shadows.shadowOf(nativeNotificationManager())
2959
+ .getNotification(updated.getHashCode())
2960
+ .fullScreenIntent;
2961
+ assertSame(pendingB, current);
2962
+ Intent[] flow = fullScreenFlow(current);
2963
+ assertEquals(
2964
+ android.net.Uri.fromParts("notifee-full-screen", ReactLaunchActivity.class.getName(), null),
2965
+ fullScreenReceiverIntent(flow).getData());
2966
+ Intent consumer = dispatchFullScreen(flow);
2967
+ assertNull(consumer.getData());
2968
+ assertNull(consumer.getAction());
2969
+ assertEquals(
2970
+ Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK, consumer.getFlags());
2971
+ assertEquals("UpdatedComponent", consumer.getStringExtra("mainComponent"));
2972
+ assertEquals(3, consumer.getExtras().size());
2973
+ Bundle result = getInitialNotification(null);
2974
+ assertInitialNotification(result, "same-id", null);
2975
+ assertEquals(1, result.size());
2976
+ assertFalse(
2977
+ result.getBundle("notification").containsKey(FullScreenNotificationActivity.LAUNCH_INTENT));
2978
+ assertNull(getInitialNotification(null));
2979
+ }
2980
+
2981
+ @Test
2982
+ public void fullScreenCapture_isCompleteBeforeConsumerStartIsInvoked() throws Exception {
2983
+ Intent carrier = fullScreenReceiverIntent(fullScreenFlow("before-dispatch"));
2984
+ EventBus.removeStickEvent(MainComponentEvent.class);
2985
+ BeforeConsumerDispatchActivity capture =
2986
+ Robolectric.buildActivity(BeforeConsumerDispatchActivity.class, carrier).create().get();
2987
+ assertEquals("FullScreenComponent", capture.componentAtDispatch);
2988
+ assertInitialNotification(capture.initialAtDispatch, "before-dispatch", null);
2989
+ assertNull(notifeeModule);
2990
+ assertNull(getInitialNotification(null));
2991
+ assertTrue(capture.isFinishing());
2992
+ int screenFlags =
2993
+ android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
2994
+ | android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON;
2995
+ assertEquals(screenFlags, capture.getWindow().getAttributes().flags & screenFlags);
2996
+ assertFalse(capture.consumer.hasExtra(FullScreenNotificationActivity.LAUNCH_INTENT));
2997
+ assertNull(capture.consumer.getData());
2998
+ }
2999
+
3000
+ @Test
3001
+ public void fullScreenCapture_missingMalformedOrForeignDestinationCannotRetireC()
3002
+ throws Exception {
3003
+ for (int variant = 0; variant < 3; variant++) {
3004
+ Intent[] c = fullScreenFlow("C-target-" + variant);
3005
+ Intent[] d = fullScreenFlow("D-target-" + variant);
3006
+ runReceiver(fullScreenReceiverIntent(c));
3007
+ Intent invalid = fullScreenReceiverIntent(d);
3008
+ if (variant == 0) invalid.removeExtra(FullScreenNotificationActivity.LAUNCH_INTENT);
3009
+ if (variant == 1)
3010
+ invalid.putExtra(FullScreenNotificationActivity.LAUNCH_INTENT, "not-an-intent");
3011
+ if (variant == 2)
3012
+ invalid.putExtra(
3013
+ FullScreenNotificationActivity.LAUNCH_INTENT,
3014
+ new Intent().setComponent(new ComponentName("other.app", "other.app.Target")));
3015
+ runReceiver(invalid);
3016
+ assertInitialNotification(getInitialNotification(null), "C-target-" + variant, null);
3017
+ assertNull(getInitialNotification(null));
3018
+ runReceiver(fullScreenReceiverIntent(d));
3019
+ assertInitialNotification(getInitialNotification(null), "D-target-" + variant, null);
3020
+ assertNull(getInitialNotification(null));
3021
+ }
3022
+ }
3023
+
3024
+ public static class BeforeConsumerDispatchActivity extends FullScreenNotificationActivity {
3025
+ String componentAtDispatch;
3026
+ Bundle initialAtDispatch;
3027
+ Intent consumer;
3028
+
3029
+ @Override
3030
+ public void startActivity(Intent intent) {
3031
+ consumer = intent;
3032
+ componentAtDispatch = NotifeeApiModule.getMainComponent("MissingComponent");
3033
+ Notifee.getInstance()
3034
+ .getInitialNotification(null, (error, value) -> initialAtDispatch = value);
3035
+ }
3036
+ }
3037
+
3038
+ @Test
3039
+ public void fullScreenCapture_unresolvableOrDisabledDestinationCannotRetireC() throws Exception {
3040
+ for (int variant = 0; variant < 2; variant++) {
3041
+ Intent[] c = fullScreenFlow("C-resolution-" + variant);
3042
+ Intent[] d = fullScreenFlow("D-resolution-" + variant);
3043
+ runReceiver(fullScreenReceiverIntent(c));
3044
+ Intent invalid = fullScreenReceiverIntent(d);
3045
+ Intent target =
3046
+ new Intent(
3047
+ (Intent) invalid.getParcelableExtra(FullScreenNotificationActivity.LAUNCH_INTENT));
3048
+ invalid.putExtra(FullScreenNotificationActivity.LAUNCH_INTENT, target);
3049
+ ComponentName component =
3050
+ new ComponentName(context.getPackageName(), "test.UndeclaredActivity");
3051
+ if (variant == 1) {
3052
+ component = new ComponentName(context, SetIntentLaunchActivity.class);
3053
+ context
3054
+ .getPackageManager()
3055
+ .setComponentEnabledSetting(
3056
+ component,
3057
+ android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
3058
+ android.content.pm.PackageManager.DONT_KILL_APP);
3059
+ }
3060
+ target.setComponent(component);
3061
+ assertNull(context.getPackageManager().resolveActivity(target, 0));
3062
+ runReceiver(invalid);
3063
+ assertInitialNotification(getInitialNotification(null), "C-resolution-" + variant, null);
3064
+ assertNull(getInitialNotification(null));
3065
+ if (variant == 1)
3066
+ context
3067
+ .getPackageManager()
3068
+ .setComponentEnabledSetting(
3069
+ component,
3070
+ android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT,
3071
+ android.content.pm.PackageManager.DONT_KILL_APP);
3072
+ runReceiver(fullScreenReceiverIntent(d));
3073
+ assertInitialNotification(getInitialNotification(null), "D-resolution-" + variant, null);
3074
+ assertNull(getInitialNotification(null));
3075
+ }
3076
+ }
3077
+
3078
+ @Test
3079
+ public void fullScreenCapture_queuedLaunchUsesNewIntentWhileCarrierIsFinishing()
3080
+ throws Exception {
3081
+ Intent[] c = fullScreenFlow("queued-C");
3082
+ Intent[] d = fullScreenFlow("queued-D");
3083
+ ActivityController<FullScreenNotificationActivity> carrier =
3084
+ Robolectric.buildActivity(FullScreenNotificationActivity.class, fullScreenReceiverIntent(c))
3085
+ .create();
3086
+ assertTrue(carrier.get().isFinishing());
3087
+ while (Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity()
3088
+ != null) {}
3089
+ carrier.newIntent(fullScreenReceiverIntent(d));
3090
+ // Framework NewIntent callbacks do not replace the Activity's creation Intent.
3091
+ assertEquals(
3092
+ "queued-C", carrier.get().getIntent().getBundleExtra("notification").getString("id"));
3093
+ Intent consumer =
3094
+ Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity();
3095
+ assertNotNull(consumer);
3096
+ assertEquals("queued-D", consumer.getBundleExtra("notification").getString("id"));
3097
+ assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK, consumer.getFlags());
3098
+ assertNull(consumer.getData());
3099
+ assertFalse(consumer.hasExtra(FullScreenNotificationActivity.LAUNCH_INTENT));
3100
+ assertInitialNotification(getInitialNotification(null), "queued-D", null);
3101
+ assertNull(getInitialNotification(null));
3102
+ simulateProcessDeath();
3103
+ Activity oldConsumer = launch(ReactLaunchActivity.class, fullScreenLaunchIntent(c)).get();
3104
+ assertNull(getInitialNotification(oldConsumer));
3105
+ runReceiver(fullScreenReceiverIntent(d));
3106
+ assertNull(getInitialNotification(null));
3107
+ }
3108
+
3109
+ // --- Helpers ---
3110
+
3111
+ /**
3112
+ * Starts a React instance as React Native 0.85 does in bridgeless mode: a new ReactContext, then
3113
+ * the TurboModules that packages declare for eager initialization, created before the JS bundle
3114
+ * runs. Other TurboModules are created when JS first uses them.
3115
+ */
3116
+ private void startReactInstance() {
3117
+ startReactInstance(null);
3118
+ }
3119
+
3120
+ /** Starts a React instance for {@code host}, already resumed, as ReactHostImpl does. */
3121
+ private void startReactInstance(Activity host) {
3122
+ createReactContext(host);
3123
+ initializeEagerTurboModules();
3124
+ }
3125
+
3126
+ /**
3127
+ * Creates the ReactContext of a new React instance and moves it to the resumed state of {@code
3128
+ * host}, if any. ReactHostImpl creates it before the instance, which only queues the creation of
3129
+ * the eager TurboModules: until they exist, the context hands new Intents to the listeners
3130
+ * registered so far (W1). Before the context exists, ReactHostImpl drops them (W0).
3131
+ */
3132
+ private void createReactContext(Activity host) {
3133
+ reactContext = new TestReactContext(context);
3134
+ ReactLaunchActivity.reactContext = reactContext;
3135
+ notifeeModule = null;
3136
+ if (host != null) {
3137
+ reactContext.onHostResume(host);
3138
+ }
3139
+ }
3140
+
3141
+ /**
3142
+ * ReactInstance#initializeEagerTurboModules: the modules of
3143
+ * ReactPackageTurboModuleManagerDelegate#getEagerInitModuleNames.
3144
+ */
3145
+ private void initializeEagerTurboModules() {
3146
+ NotifeePackage notifeePackage = new NotifeePackage();
3147
+ for (ReactModuleInfo moduleInfo :
3148
+ notifeePackage.getReactModuleInfoProvider().getReactModuleInfos().values()) {
3149
+ if (moduleInfo.isTurboModule() && moduleInfo.needsEagerInit()) {
3150
+ notifeeModule =
3151
+ (NotifeeApiModule) notifeePackage.getModule(moduleInfo.name(), reactContext);
3152
+ }
3153
+ }
3154
+ }
3155
+
3156
+ /** Reloads JS: the instance's modules are invalidated and a new instance hosts the Activity. */
3157
+ private TestReactContext reloadReactInstance(Activity activity) {
3158
+ TestReactContext previous = reactContext;
3159
+ if (notifeeModule != null) {
3160
+ notifeeModule.invalidate();
3161
+ }
3162
+ startReactInstance();
3163
+ reactContext.onHostResume(activity);
3164
+ return previous;
3165
+ }
3166
+
3167
+ /**
3168
+ * Calls getInitialNotification() as JS does: through the Notifee TurboModule (created on first
3169
+ * use unless the React instance created it), which passes its ReactContext's current Activity.
3170
+ */
3171
+ private Bundle jsGetInitialNotification() {
3172
+ if (notifeeModule == null) {
3173
+ notifeeModule =
3174
+ (NotifeeApiModule) new NotifeePackage().getModule(NotifeeApiModule.NAME, reactContext);
3175
+ }
3176
+ Promise promise = mock(Promise.class);
3177
+ WritableMap resolvedMap = mock(WritableMap.class);
3178
+ List<Bundle> resolvedBundles = new ArrayList<>();
3179
+ try (MockedStatic<Arguments> arguments = mockStatic(Arguments.class)) {
3180
+ arguments
3181
+ .when(() -> Arguments.fromBundle(any(Bundle.class)))
3182
+ .thenAnswer(
3183
+ invocation -> {
3184
+ resolvedBundles.add(invocation.getArgument(0));
3185
+ return resolvedMap;
3186
+ });
3187
+ notifeeModule.getInitialNotification(promise);
3188
+ }
3189
+
3190
+ ArgumentCaptor<Object> resolved = ArgumentCaptor.forClass(Object.class);
3191
+ verify(promise).resolve(resolved.capture());
3192
+ if (resolved.getValue() == null) {
3193
+ assertTrue(resolvedBundles.isEmpty());
3194
+ return null;
3195
+ }
3196
+ assertSame(resolvedMap, resolved.getValue());
3197
+ assertEquals(1, resolvedBundles.size());
3198
+ return resolvedBundles.get(0);
3199
+ }
3200
+
3201
+ private static Bundle getInitialNotification(Activity activity) {
3202
+ AtomicReference<Exception> error = new AtomicReference<>();
3203
+ AtomicReference<Bundle> result = new AtomicReference<>();
3204
+ CountDownLatch completed = new CountDownLatch(1);
3205
+
3206
+ Notifee.getInstance()
3207
+ .getInitialNotification(
3208
+ activity,
3209
+ (e, bundle) -> {
3210
+ error.set(e);
3211
+ result.set(bundle);
3212
+ completed.countDown();
3213
+ });
3214
+
3215
+ assertEquals("getInitialNotification must complete synchronously", 0, completed.getCount());
3216
+ assertNull(error.get());
3217
+ return result.get();
3218
+ }
3219
+
3220
+ /**
3221
+ * @param pressActionId expected pressAction id, carried by interactions delivered through the
3222
+ * sticky event; {@code null} when the pressAction is not part of the expectation.
3223
+ */
3224
+ private static void assertInitialNotification(
3225
+ Bundle initialNotification, String notificationId, String pressActionId) {
3226
+ assertNotNull("expected initial notification " + notificationId, initialNotification);
3227
+ Bundle notification = initialNotification.getBundle("notification");
3228
+ assertNotNull("initial notification must carry the notification", notification);
3229
+ assertEquals(notificationId, notification.getString("id"));
3230
+
3231
+ if (pressActionId != null) {
3232
+ Bundle pressAction = initialNotification.getBundle("pressAction");
3233
+ assertNotNull("initial notification must carry the pressAction", pressAction);
3234
+ assertEquals(pressActionId, pressAction.getString("id"));
3235
+ }
3236
+ }
3237
+
3238
+ private static <T extends Activity> ActivityController<T> launch(
3239
+ Class<T> activityClass, Intent intent) {
3240
+ return Robolectric.buildActivity(activityClass, intent).create().start().resume();
3241
+ }
3242
+
3243
+ /** Backgrounds the Activity the way the system does before it may kill the process. */
3244
+ private static Bundle moveToBackground(ActivityController<?> controller) {
3245
+ Bundle savedState = new Bundle();
3246
+ controller.pause().stop().saveInstanceState(savedState);
3247
+ return savedState;
3248
+ }
3249
+
3250
+ /**
3251
+ * Models the death of the app process: in-memory state (sticky events, statics, loaded
3252
+ * SharedPreferences, the React instance) is lost, app storage survives and is read again from
3253
+ * disk.
3254
+ */
3255
+ private static void simulateProcessDeath() {
3256
+ clearStickyEvents();
3257
+ Notifee.resetInitialNotificationStateForTesting();
3258
+ ShadowContextImpl.reset();
3259
+ ReactLaunchActivity.reactContext = null;
3260
+ }
3261
+
3262
+ /** Recreates an Activity the way the system restores it in a new process. */
3263
+ private static <T extends Activity> ActivityController<T> restoreController(
3264
+ Class<T> activityClass, Intent intent, Bundle savedState) {
3265
+ return Robolectric.buildActivity(activityClass, intent)
3266
+ .create(savedState)
3267
+ .start()
3268
+ .restoreInstanceState(savedState)
3269
+ .postCreate(savedState)
3270
+ .resume();
3271
+ }
3272
+
3273
+ private static <T extends Activity> Activity restore(
3274
+ Class<T> activityClass, Intent intent, Bundle savedState) {
3275
+ return restoreController(activityClass, intent, savedState).get();
3276
+ }
3277
+
3278
+ private static Intent launcherIntent() {
3279
+ return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
3280
+ }
3281
+
3282
+ private static void collect(List<Bundle> delivered, Bundle initialNotification) {
3283
+ if (initialNotification != null) {
3284
+ delivered.add(initialNotification);
3285
+ }
3286
+ }
3287
+
3288
+ private static void assertDeliveredOnce(List<Bundle> delivered, String notificationId) {
3289
+ assertEquals(notificationId + " must be delivered exactly once", 1, delivered.size());
3290
+ assertInitialNotification(delivered.get(0), notificationId, null);
3291
+ }
3292
+
3293
+ /** Intents the system starts, bottom to top, when the user taps {@code pendingIntent}. */
3294
+ private static Intent[] launchFlow(PendingIntent pendingIntent) {
3295
+ assertNotNull("PendingIntent must be present", pendingIntent);
3296
+ ShadowPendingIntent shadowPendingIntent = Shadows.shadowOf(pendingIntent);
3297
+ assertTrue("Android 12+ launch flow must start activities", shadowPendingIntent.isActivity());
3298
+ Intent[] intents = shadowPendingIntent.getSavedIntents();
3299
+ assertEquals("launch flow must start the app and the receiver", 2, intents.length);
3300
+ assertEquals(
3301
+ NotificationReceiverActivity.class.getName(), intents[1].getComponent().getClassName());
3302
+ return intents;
3303
+ }
3304
+
3305
+ /** Displays a full-screen notification and returns the Intent its launch Activity starts with. */
3306
+ private Intent fullScreenIntent(String id) throws Exception {
3307
+ return fullScreenLaunchIntent(
3308
+ display(fullScreenNotification(id, "FullScreenComponent")).fullScreenIntent);
3309
+ }
3310
+
3311
+ /** Displays, launches and consumes a full-screen notification; returns its launch Intent. */
3312
+ private Intent consumeFullScreenLaunch(String id) throws Exception {
3313
+ Intent fullScreenIntent = fullScreenIntent(id);
3314
+ Activity activity = launch(DefaultLaunchActivity.class, new Intent(fullScreenIntent)).get();
3315
+ assertInitialNotification(getInitialNotification(activity), id, null);
3316
+ assertNull(getInitialNotification(activity));
3317
+ return fullScreenIntent;
3318
+ }
3319
+
3320
+ /** Runs {@code action} while the app's SharedPreferences cannot be written to disk. */
3321
+ private <T> T withFailingPreferenceWrites(Callable<T> action) throws Exception {
3322
+ File preferencesDir = new File(context.getDataDir(), "shared_prefs");
3323
+ assertTrue(preferencesDir.isDirectory() || preferencesDir.mkdirs());
3324
+ assertTrue(preferencesDir.setWritable(false));
3325
+ try {
3326
+ SharedPreferences probe =
3327
+ context.getSharedPreferences("write-failure-probe", Context.MODE_PRIVATE);
3328
+ assertFalse("writes must fail", probe.edit().putLong("probe", System.nanoTime()).commit());
3329
+ return action.call();
3330
+ } finally {
3331
+ preferencesDir.setWritable(true);
3332
+ }
3333
+ }
3334
+
3335
+ /** Capture carrier and destination used to drive callbacks; raw PI shape is tested separately. */
3336
+ private static Intent[] fullScreenFlow(PendingIntent pendingIntent) {
3337
+ assertNotNull("PendingIntent must be present", pendingIntent);
3338
+ ShadowPendingIntent shadow = Shadows.shadowOf(pendingIntent);
3339
+ assertTrue(shadow.isActivity());
3340
+ Intent[] saved = shadow.getSavedIntents();
3341
+ assertEquals("full-screen PI must have one target", 1, saved.length);
3342
+ Intent carrier = new Intent(saved[0]);
3343
+ if (!isReceiverIntent(carrier)) return new Intent[] {carrier}; // unchanged legacy
3344
+ Intent destination = carrier.getParcelableExtra(FullScreenNotificationActivity.LAUNCH_INTENT);
3345
+ assertNotNull(destination);
3346
+ Intent consumer = new Intent(destination).replaceExtras(new Bundle(carrier.getExtras()));
3347
+ consumer.removeExtra(FullScreenNotificationActivity.LAUNCH_INTENT);
3348
+ return new Intent[] {carrier, consumer};
3349
+ }
3350
+
3351
+ /** The Intent a full-screen launch starts its launch Activity with. */
3352
+ private static Intent fullScreenLaunchIntent(PendingIntent pendingIntent) {
3353
+ return fullScreenLaunchIntent(fullScreenFlow(pendingIntent));
3354
+ }
3355
+
3356
+ private static Intent fullScreenLaunchIntent(Intent[] flow) {
3357
+ for (Intent intent : flow) {
3358
+ if (!isReceiverIntent(intent)) {
3359
+ return new Intent(intent);
3360
+ }
3361
+ }
3362
+ throw new AssertionError("full-screen consumer Intent must be present");
3363
+ }
3364
+
3365
+ /** The sole capture target; it starts the consumer from its own callback. */
3366
+ private static Intent fullScreenReceiverIntent(Intent[] flow) {
3367
+ assertEquals("full-screen launch must start the receiver and the app", 2, flow.length);
3368
+ for (Intent intent : flow) {
3369
+ if (isReceiverIntent(intent)) {
3370
+ return new Intent(intent);
3371
+ }
3372
+ }
3373
+ throw new AssertionError("full-screen receiver Intent must be present");
3374
+ }
3375
+
3376
+ private static boolean isReceiverIntent(Intent intent) {
3377
+ return intent.getComponent() != null
3378
+ && FullScreenNotificationActivity.class
3379
+ .getName()
3380
+ .equals(intent.getComponent().getClassName());
3381
+ }
3382
+
3383
+ /** Displays a full-screen notification launching {@code launchActivity}; returns its flow. */
3384
+ private Intent[] fullScreenFlow(String id, Class<? extends Activity> launchActivity)
3385
+ throws Exception {
3386
+ return fullScreenFlow(
3387
+ display(fullScreenNotification(id, "FullScreenComponent", launchActivity.getName()))
3388
+ .fullScreenIntent);
3389
+ }
3390
+
3391
+ /** Displays a full-screen notification launching {@link ReactLaunchActivity}. */
3392
+ private Intent[] fullScreenFlow(String id) throws Exception {
3393
+ return fullScreenFlow(id, ReactLaunchActivity.class);
3394
+ }
3395
+
3396
+ /**
3397
+ * Drives the capture callback and then delivery of its destination to an existing consumer. Raw
3398
+ * PI and synchronous dispatch are independently asserted by the shape/dispatch witnesses.
3399
+ */
3400
+ private static void deliverFullScreen(ActivityController<?> host, Intent[] flow) {
3401
+ host.newIntent(dispatchFullScreen(flow));
3402
+ }
3403
+
3404
+ private static Intent dispatchFullScreen(Intent[] flow) {
3405
+ // Use the Intent actually started by production's capture callback, rather than synthesizing
3406
+ // delivery of the nested destination when the framework might never execute the capture.
3407
+ while (Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity()
3408
+ != null) {}
3409
+ runReceiver(fullScreenReceiverIntent(flow));
3410
+ Intent actual = Shadows.shadowOf(RuntimeEnvironment.getApplication()).getNextStartedActivity();
3411
+ assertNotNull("capture must synchronously dispatch its consumer", actual);
3412
+ assertEquals(fullScreenLaunchIntent(flow).getComponent(), actual.getComponent());
3413
+ assertEquals(fullScreenLaunchIntent(flow).getFlags(), actual.getFlags());
3414
+ assertFalse(actual.hasExtra(FullScreenNotificationActivity.LAUNCH_INTENT));
3415
+ return actual;
3416
+ }
3417
+
3418
+ /** Starts the consumer Intent actually dispatched by the capture callback. */
3419
+ private static <T extends Activity> ActivityController<T> launchFromFullScreen(
3420
+ Class<T> activityClass, Intent[] flow) {
3421
+ return launch(activityClass, dispatchFullScreen(flow));
3422
+ }
3423
+
3424
+ private static void runReceiver(Intent[] flow) {
3425
+ runReceiver(new Intent(flow[1]));
3426
+ }
3427
+
3428
+ private static void runReceiver(Intent receiverIntent) {
3429
+ if (isReceiverIntent(receiverIntent)) {
3430
+ Robolectric.buildActivity(FullScreenNotificationActivity.class, receiverIntent).create();
3431
+ } else {
3432
+ Robolectric.buildActivity(NotificationReceiverActivity.class, receiverIntent).create();
3433
+ }
3434
+ }
3435
+
3436
+ /** Delivers one tap and returns the new Intent the launch Activity receives for it. */
3437
+ private static Intent tap(Intent[] flow) {
3438
+ runReceiver(flow);
3439
+ return new Intent(flow[0]);
3440
+ }
3441
+
3442
+ private static Intent tapWithInput(Intent[] flow, String input) {
3443
+ Intent receiverIntent = new Intent(flow[1]);
3444
+ Bundle results = new Bundle();
3445
+ results.putCharSequence(NotificationReceiverHandler.REMOTE_INPUT_RECEIVER_KEY, input);
3446
+ RemoteInput.addResultsToIntent(
3447
+ new RemoteInput[] {
3448
+ new RemoteInput.Builder(NotificationReceiverHandler.REMOTE_INPUT_RECEIVER_KEY).build()
3449
+ },
3450
+ receiverIntent,
3451
+ results);
3452
+ runReceiver(receiverIntent);
3453
+ return new Intent(flow[0]);
3454
+ }
3455
+
3456
+ private Notification display(NotificationModel notificationModel) throws Exception {
3457
+ ShadowNotificationManager shadowNotificationManager =
3458
+ Shadows.shadowOf(nativeNotificationManager());
3459
+ NotificationManager.displayNotification(notificationModel, null).get(5, TimeUnit.SECONDS);
3460
+
3461
+ Notification notification =
3462
+ shadowNotificationManager.getNotification(notificationModel.getHashCode());
3463
+ assertNotNull("notification must be posted", notification);
3464
+ return notification;
3465
+ }
3466
+
3467
+ private android.app.NotificationManager nativeNotificationManager() {
3468
+ return (android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
3469
+ }
3470
+
3471
+ private static NotificationModel pressNotification(String id) {
3472
+ return notification(id, androidWithPressAction(pressAction("default", "default", null)));
3473
+ }
3474
+
3475
+ private static NotificationModel nonAutoCancelPressNotification(String id) {
3476
+ Bundle androidBundle = androidWithPressAction(pressAction("default", "default", null));
3477
+ androidBundle.putBoolean("autoCancel", false);
3478
+ return notification(id, androidBundle);
3479
+ }
3480
+
3481
+ private static NotificationModel actionNotification(String id, Bundle actionPressAction) {
3482
+ Bundle action = new Bundle();
3483
+ action.putString("title", "Action " + actionPressAction.getString("id"));
3484
+ action.putBundle("pressAction", actionPressAction);
3485
+ ArrayList<Bundle> actions = new ArrayList<>();
3486
+ actions.add(action);
3487
+
3488
+ Bundle androidBundle = androidWithPressAction(pressAction("default", "default", null));
3489
+ androidBundle.putParcelableArrayList("actions", actions);
3490
+ return notification(id, androidBundle);
3491
+ }
3492
+
3493
+ private static NotificationModel fullScreenNotification(String id, String mainComponent) {
3494
+ return fullScreenNotification(id, mainComponent, "default");
3495
+ }
3496
+
3497
+ private static NotificationModel fullScreenNotification(
3498
+ String id, String mainComponent, String launchActivity) {
3499
+ return fullScreenNotification(
3500
+ id, pressAction("default", launchActivity, mainComponent), new Bundle());
3501
+ }
3502
+
3503
+ private static NotificationModel fullScreenNotification(
3504
+ String id, Bundle fullScreenAction, Bundle androidBundle) {
3505
+ androidBundle.putString("channelId", CHANNEL_ID);
3506
+ androidBundle.putBundle("pressAction", pressAction("default", "default", null));
3507
+ androidBundle.putBundle("fullScreenAction", fullScreenAction);
3508
+ return notification(id, androidBundle);
3509
+ }
3510
+
3511
+ private static Bundle androidWithPressAction(Bundle pressAction) {
3512
+ Bundle androidBundle = new Bundle();
3513
+ androidBundle.putString("channelId", CHANNEL_ID);
3514
+ androidBundle.putBundle("pressAction", pressAction);
3515
+ return androidBundle;
3516
+ }
3517
+
3518
+ private static NotificationModel notification(String id, Bundle androidBundle) {
3519
+ Bundle notificationBundle = new Bundle();
3520
+ notificationBundle.putString("id", id);
3521
+ notificationBundle.putString("title", "Initial notification " + id);
3522
+ notificationBundle.putString("body", "Body " + id);
3523
+ notificationBundle.putBundle("android", androidBundle);
3524
+ return NotificationModel.fromBundle(notificationBundle);
3525
+ }
3526
+
3527
+ private static Bundle pressAction(String id, String launchActivity, String mainComponent) {
3528
+ Bundle pressAction = new Bundle();
3529
+ pressAction.putString("id", id);
3530
+ if (launchActivity != null) {
3531
+ pressAction.putString("launchActivity", launchActivity);
3532
+ }
3533
+ if (mainComponent != null) {
3534
+ pressAction.putString("mainComponent", mainComponent);
3535
+ }
3536
+ return pressAction;
3537
+ }
3538
+
3539
+ private void registerDefaultLaunchActivity() {
3540
+ Intent launcherIntent = new Intent(Intent.ACTION_MAIN);
3541
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
3542
+ launcherIntent.setPackage(context.getPackageName());
3543
+
3544
+ ResolveInfo resolveInfo = new ResolveInfo();
3545
+ resolveInfo.activityInfo = new ActivityInfo();
3546
+ resolveInfo.activityInfo.packageName = context.getPackageName();
3547
+ resolveInfo.activityInfo.name = DefaultLaunchActivity.class.getName();
3548
+
3549
+ ShadowPackageManager shadowPackageManager = Shadows.shadowOf(context.getPackageManager());
3550
+ shadowPackageManager.addActivityIfNotPresent(
3551
+ new ComponentName(context.getPackageName(), DefaultLaunchActivity.class.getName()));
3552
+ shadowPackageManager.addResolveInfoForIntent(launcherIntent, resolveInfo);
3553
+ }
3554
+
3555
+ private void createChannel() {
3556
+ android.app.NotificationManager notificationManager = nativeNotificationManager();
3557
+ if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
3558
+ notificationManager.createNotificationChannel(
3559
+ new NotificationChannel(
3560
+ CHANNEL_ID,
3561
+ "Initial notification consumption test",
3562
+ android.app.NotificationManager.IMPORTANCE_LOW));
3563
+ }
3564
+ }
3565
+
3566
+ private static void setTargetSdk(int targetSdk) {
3567
+ RuntimeEnvironment.getApplication().getApplicationInfo().targetSdkVersion = targetSdk;
3568
+ }
3569
+
3570
+ private static void clearStickyEvents() {
3571
+ EventBus.removeStickEvent(InitialNotificationEvent.class);
3572
+ EventBus.removeStickEvent(MainComponentEvent.class);
3573
+ }
3574
+
3575
+ /**
3576
+ * Default {@code onNewIntent}: {@link #getIntent()} keeps the creation Intent. ReactActivity also
3577
+ * hands new Intents to React Native: {@link ReactLaunchActivity}.
3578
+ */
3579
+ public static class DefaultLaunchActivity extends Activity {}
3580
+
3581
+ /** App-level pattern that replaces {@link #getIntent()} on every new Intent. */
3582
+ public static class SetIntentLaunchActivity extends Activity {
3583
+ @Override
3584
+ protected void onNewIntent(Intent intent) {
3585
+ super.onNewIntent(intent);
3586
+ setIntent(intent);
3587
+ }
3588
+ }
3589
+
3590
+ /**
3591
+ * Launch Activity hosting React Native as ReactActivity does in bridgeless mode (React Native
3592
+ * 0.85): its lifecycle and new Intents go to the React instance (ReactDelegate, ReactHostImpl),
3593
+ * which hands them to its ReactContext. onNewIntent() calls neither super.onNewIntent() nor
3594
+ * setIntent(), so {@link #getIntent()} keeps the creation Intent. Until the React instance has a
3595
+ * ReactContext ({@link #reactContext} null), ReactHostImpl drops new Intents.
3596
+ */
3597
+ public static class ComponentCheckingLaunchActivity extends ReactLaunchActivity {
3598
+ String selectedMainComponent;
3599
+
3600
+ @Override
3601
+ protected void onCreate(Bundle state) {
3602
+ selectedMainComponent = NotifeeApiModule.getMainComponent("DefaultComponent");
3603
+ super.onCreate(state);
3604
+ }
3605
+ }
3606
+
3607
+ public static class ReactLaunchActivity extends Activity {
3608
+ static TestReactContext reactContext;
3609
+
3610
+ @Override
3611
+ protected void onResume() {
3612
+ super.onResume();
3613
+ if (reactContext != null) {
3614
+ reactContext.onHostResume(this);
3615
+ }
3616
+ }
3617
+
3618
+ @Override
3619
+ protected void onPause() {
3620
+ super.onPause();
3621
+ if (reactContext != null) {
3622
+ reactContext.onHostPause();
3623
+ }
3624
+ }
3625
+
3626
+ @Override
3627
+ protected void onDestroy() {
3628
+ super.onDestroy();
3629
+ // ReactHostImpl#onHostDestroy(Activity) acts for the current Activity only.
3630
+ if (reactContext != null && reactContext.getCurrentActivity() == this) {
3631
+ reactContext.onHostDestroy();
3632
+ }
3633
+ }
3634
+
3635
+ @Override
3636
+ protected void onNewIntent(Intent intent) {
3637
+ // ReactHostImpl#onNewIntent: handed over with the host's current Activity.
3638
+ if (reactContext != null) {
3639
+ reactContext.onNewIntent(reactContext.getCurrentActivity(), intent);
3640
+ }
3641
+ }
3642
+ }
3643
+
3644
+ /** React Native host whose app code clears a new Intent's extras once it handled them. */
3645
+ public static class ReactExtrasClearingLaunchActivity extends ReactLaunchActivity {
3646
+ @Override
3647
+ protected void onNewIntent(Intent intent) {
3648
+ super.onNewIntent(intent);
3649
+ intent.replaceExtras((Bundle) null);
3650
+ }
3651
+ }
3652
+
3653
+ /** React Native host whose app code also replaces {@link #getIntent()} on every new Intent. */
3654
+ public static class ReactSetIntentLaunchActivity extends ReactLaunchActivity {
3655
+ @Override
3656
+ protected void onNewIntent(Intent intent) {
3657
+ super.onNewIntent(intent);
3658
+ setIntent(intent);
3659
+ }
3660
+ }
3661
+
3662
+ /**
3663
+ * ReactContext of a bridgeless React instance, limited to Activity events: lifecycle and new
3664
+ * Intent dispatch are ReactContext's own, which BridgelessReactContext does not override.
3665
+ * Exceptions thrown by listeners are recorded instead of being reported to the ReactHost.
3666
+ */
3667
+ static class TestReactContext extends ReactApplicationContext {
3668
+ final List<Exception> listenerExceptions = Collections.synchronizedList(new ArrayList<>());
3669
+ final List<ActivityEventListener> activityEventListeners =
3670
+ Collections.synchronizedList(new ArrayList<>());
3671
+
3672
+ TestReactContext(Context context) {
3673
+ super(context);
3674
+ }
3675
+
3676
+ @Override
3677
+ public void addActivityEventListener(ActivityEventListener listener) {
3678
+ activityEventListeners.add(listener);
3679
+ super.addActivityEventListener(listener);
3680
+ }
3681
+
3682
+ @Override
3683
+ public void handleException(Exception e) {
3684
+ listenerExceptions.add(e);
3685
+ }
3686
+
3687
+ @Override
3688
+ public boolean isBridgeless() {
3689
+ return true;
3690
+ }
3691
+
3692
+ @Override
3693
+ public boolean hasActiveReactInstance() {
3694
+ return true;
3695
+ }
3696
+
3697
+ @Override
3698
+ public boolean hasReactInstance() {
3699
+ return true;
3700
+ }
3701
+
3702
+ @Override
3703
+ public boolean hasActiveCatalystInstance() {
3704
+ return true;
3705
+ }
3706
+
3707
+ @Override
3708
+ public boolean hasCatalystInstance() {
3709
+ return false;
3710
+ }
3711
+
3712
+ @Override
3713
+ public void destroy() {}
3714
+
3715
+ @Override
3716
+ public <T extends JavaScriptModule> T getJSModule(Class<T> jsInterface) {
3717
+ throw new UnsupportedOperationException();
3718
+ }
3719
+
3720
+ @Override
3721
+ public <T extends NativeModule> boolean hasNativeModule(Class<T> nativeModuleInterface) {
3722
+ return false;
3723
+ }
3724
+
3725
+ @Override
3726
+ public Collection<NativeModule> getNativeModules() {
3727
+ return Collections.emptyList();
3728
+ }
3729
+
3730
+ @Override
3731
+ public <T extends NativeModule> T getNativeModule(Class<T> nativeModuleInterface) {
3732
+ return null;
3733
+ }
3734
+
3735
+ @Override
3736
+ public NativeModule getNativeModule(String moduleName) {
3737
+ return null;
3738
+ }
3739
+
3740
+ @Override
3741
+ public CatalystInstance getCatalystInstance() {
3742
+ throw new UnsupportedOperationException();
3743
+ }
3744
+
3745
+ @Override
3746
+ public JavaScriptContextHolder getJavaScriptContextHolder() {
3747
+ return null;
3748
+ }
3749
+
3750
+ @Override
3751
+ public CallInvokerHolder getJSCallInvokerHolder() {
3752
+ return null;
3753
+ }
3754
+
3755
+ @Override
3756
+ public UIManager getFabricUIManager() {
3757
+ return null;
3758
+ }
3759
+
3760
+ @Override
3761
+ public String getSourceURL() {
3762
+ return null;
3763
+ }
3764
+
3765
+ @Override
3766
+ public void registerSegment(int segmentId, String path, Callback callback) {}
3767
+ }
3768
+
3769
+ /** Records the tap events Notifee posts to its event subscribers. */
3770
+ public static class NotificationEventRecorder {
3771
+ final List<NotificationEvent> taps = Collections.synchronizedList(new ArrayList<>());
3772
+
3773
+ @Subscribe
3774
+ public void onNotificationEvent(NotificationEvent event) {
3775
+ if (event.getType() == NotificationEvent.TYPE_PRESS
3776
+ || event.getType() == NotificationEvent.TYPE_ACTION_PRESS) {
3777
+ taps.add(event);
3778
+ }
3779
+ }
3780
+ }
3781
+
3782
+ private NotificationEventRecorder recordTapEvents() {
3783
+ eventRecorder = new NotificationEventRecorder();
3784
+ EventBus.register(eventRecorder);
3785
+ return eventRecorder;
3786
+ }
3787
+
3788
+ /** Intent whose extras cannot be read, as when its sender put classes this app does not have. */
3789
+ private static class UnreadableExtrasIntent extends Intent {
3790
+ @Override
3791
+ public boolean hasExtra(String name) {
3792
+ throw new BadParcelableException("ClassNotFoundException when unmarshalling");
3793
+ }
3794
+
3795
+ @Override
3796
+ public String getStringExtra(String name) {
3797
+ throw new BadParcelableException("ClassNotFoundException when unmarshalling");
3798
+ }
3799
+
3800
+ @Override
3801
+ public Bundle getBundleExtra(String name) {
3802
+ throw new BadParcelableException("ClassNotFoundException when unmarshalling");
3803
+ }
3804
+ }
3805
+ }