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.
- package/README.md +18 -0
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/app/notifee/core/EventBus.java +4 -0
- package/android/src/main/java/app/notifee/core/FullScreenNotificationActivity.java +76 -0
- package/android/src/main/java/app/notifee/core/InitialNotificationLedger.java +262 -0
- package/android/src/main/java/app/notifee/core/Notifee.java +142 -20
- package/android/src/main/java/app/notifee/core/NotificationManager.java +44 -6
- package/android/src/main/java/app/notifee/core/NotificationPendingIntent.java +14 -0
- package/android/src/main/java/app/notifee/core/NotificationReceiverHandler.java +11 -2
- package/android/src/main/java/app/notifee/core/event/InitialNotificationEvent.java +15 -0
- package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +23 -0
- package/android/src/test/java/app/notifee/core/NotifeeGetInitialNotificationTest.java +3805 -0
- package/android/src/test/java/io/invertase/notifee/PermissionRationaleTest.java +144 -0
- package/cli/package.json +1 -1
- package/dist/NotifeeApiModule.d.ts +1 -0
- package/dist/NotifeeApiModule.js +6 -0
- package/dist/NotifeeApiModule.js.map +1 -1
- package/dist/specs/NativeNotifeeModule.d.ts +1 -0
- package/dist/specs/NativeNotifeeModule.js.map +1 -1
- package/dist/types/Module.d.ts +29 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/ios/RNNotifee/NotifeeApiModule.mm +5 -0
- package/jest-mock.js +1 -0
- package/package.json +1 -1
- package/src/NotifeeApiModule.ts +8 -0
- package/src/specs/NativeNotifeeModule.ts +1 -0
- package/src/types/Module.ts +30 -3
- 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
|
+
}
|