react-native-notify-kit 10.8.1 → 10.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +35 -18
  2. package/android/build.gradle +50 -4
  3. package/android/src/main/AndroidManifest.xml +15 -0
  4. package/android/src/main/java/app/notifee/core/EventBus.java +4 -0
  5. package/android/src/main/java/app/notifee/core/ForegroundService.java +395 -5
  6. package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitReceiver.java +15 -0
  7. package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitScheduler.java +461 -0
  8. package/android/src/main/java/app/notifee/core/FullScreenNotificationActivity.java +76 -0
  9. package/android/src/main/java/app/notifee/core/InitialNotificationLedger.java +262 -0
  10. package/android/src/main/java/app/notifee/core/Notifee.java +142 -20
  11. package/android/src/main/java/app/notifee/core/NotificationManager.java +50 -6
  12. package/android/src/main/java/app/notifee/core/NotificationPendingIntent.java +14 -0
  13. package/android/src/main/java/app/notifee/core/NotificationReceiverHandler.java +11 -2
  14. package/android/src/main/java/app/notifee/core/event/InitialNotificationEvent.java +15 -0
  15. package/android/src/main/java/app/notifee/core/event/NotificationEvent.java +6 -0
  16. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +29 -0
  17. package/android/src/main/kotlin/io/invertase/notifee/NotifeeEventSubscriber.kt +3 -0
  18. package/android/src/test/java/app/notifee/core/ForegroundServiceRunLimitTest.java +2357 -0
  19. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +23 -13
  20. package/android/src/test/java/app/notifee/core/NotifeeGetInitialNotificationTest.java +3805 -0
  21. package/android/src/test/java/app/notifee/core/ShadowServiceCompatibilityTest.java +170 -0
  22. package/android/src/test/resources/robolectric.properties +4 -0
  23. package/cli/dist/cli.js +0 -1
  24. package/cli/dist/commands/initNse.js +1 -2
  25. package/cli/dist/lib/detectProject.js +1 -2
  26. package/cli/dist/lib/initNseCore.js +0 -1
  27. package/cli/dist/lib/logger.js +0 -1
  28. package/cli/dist/lib/patchPodfile.js +0 -1
  29. package/cli/dist/lib/patchXcodeProject.js +22 -3
  30. package/cli/dist/lib/writeTemplates.js +0 -1
  31. package/cli/dist/lib/xcode.js +6 -0
  32. package/cli/dist/templates/NotificationService.swift.tmpl +74 -6
  33. package/cli/package.json +1 -0
  34. package/cli/vendor/xcode/AUTHORS +6 -0
  35. package/cli/vendor/xcode/LICENSE +202 -0
  36. package/cli/vendor/xcode/NOTICE +5 -0
  37. package/cli/vendor/xcode/README.md +32 -0
  38. package/cli/vendor/xcode/UPSTREAM-SHA256.json +12 -0
  39. package/cli/vendor/xcode/index.js +18 -0
  40. package/cli/vendor/xcode/lib/parseJob.js +32 -0
  41. package/cli/vendor/xcode/lib/parser/pbxproj.js +1904 -0
  42. package/cli/vendor/xcode/lib/parser/pbxproj.pegjs +280 -0
  43. package/cli/vendor/xcode/lib/pbxFile.js +233 -0
  44. package/cli/vendor/xcode/lib/pbxProject.js +2205 -0
  45. package/cli/vendor/xcode/lib/pbxWriter.js +309 -0
  46. package/dist/NotifeeNativeModule.web.d.ts +2 -2
  47. package/dist/types/Module.d.ts +10 -3
  48. package/dist/types/Notification.d.ts +12 -1
  49. package/dist/types/Notification.js +3 -0
  50. package/dist/types/Notification.js.map +1 -1
  51. package/dist/types/NotificationAndroid.d.ts +9 -0
  52. package/dist/types/NotificationAndroid.js.map +1 -1
  53. package/dist/validators/validateAndroidNotification.js +26 -1
  54. package/dist/validators/validateAndroidNotification.js.map +1 -1
  55. package/dist/validators/validateAndroidStyle.js +6 -0
  56. package/dist/validators/validateAndroidStyle.js.map +1 -1
  57. package/dist/validators/validateIOSAttachment.js +3 -1
  58. package/dist/validators/validateIOSAttachment.js.map +1 -1
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +8 -5
  62. package/src/NotifeeNativeModule.web.ts +2 -2
  63. package/src/types/Module.ts +10 -3
  64. package/src/types/Notification.ts +13 -0
  65. package/src/types/NotificationAndroid.ts +10 -0
  66. package/src/validators/validateAndroidNotification.ts +31 -1
  67. package/src/validators/validateAndroidStyle.ts +10 -2
  68. package/src/validators/validateIOSAttachment.ts +5 -1
  69. package/src/version.ts +1 -1
  70. package/cli/dist/cli.bundle.js +0 -20834
  71. package/cli/dist/cli.d.ts +0 -2
  72. package/cli/dist/cli.js.map +0 -1
  73. package/cli/dist/commands/initNse.d.ts +0 -12
  74. package/cli/dist/commands/initNse.js.map +0 -1
  75. package/cli/dist/lib/detectProject.d.ts +0 -12
  76. package/cli/dist/lib/detectProject.js.map +0 -1
  77. package/cli/dist/lib/initNseCore.d.ts +0 -8
  78. package/cli/dist/lib/initNseCore.js.map +0 -1
  79. package/cli/dist/lib/logger.d.ts +0 -4
  80. package/cli/dist/lib/logger.js.map +0 -1
  81. package/cli/dist/lib/patchPodfile.d.ts +0 -23
  82. package/cli/dist/lib/patchPodfile.js.map +0 -1
  83. package/cli/dist/lib/patchXcodeProject.d.ts +0 -41
  84. package/cli/dist/lib/patchXcodeProject.js.map +0 -1
  85. package/cli/dist/lib/writeTemplates.d.ts +0 -11
  86. package/cli/dist/lib/writeTemplates.js.map +0 -1
  87. package/cli/dist/templates/templates/Info.plist.tmpl +0 -29
  88. package/cli/dist/templates/templates/NotificationService.swift.tmpl +0 -100
  89. package/cli/dist/templates/templates/NotifyKitNSE.entitlements.tmpl +0 -6
@@ -0,0 +1,2357 @@
1
+ package app.notifee.core;
2
+
3
+ import static org.junit.Assert.*;
4
+ import static org.robolectric.Shadows.shadowOf;
5
+
6
+ import android.app.*;
7
+ import android.content.*;
8
+ import android.content.pm.*;
9
+ import android.os.*;
10
+ import android.provider.Settings;
11
+ import androidx.core.app.NotificationCompat;
12
+ import java.lang.reflect.*;
13
+ import java.time.Duration;
14
+ import java.util.*;
15
+ import org.junit.*;
16
+ import org.junit.runner.RunWith;
17
+ import org.robolectric.*;
18
+ import org.robolectric.android.controller.ServiceController;
19
+ import org.robolectric.annotation.*;
20
+ import org.robolectric.shadows.*;
21
+
22
+ /**
23
+ * Causal host oracle: acceptance, delivery, promotion, alarm registration and stop are separate.
24
+ */
25
+ @RunWith(RobolectricTestRunner.class)
26
+ @Config(
27
+ sdk = 35,
28
+ shadows = {
29
+ ForegroundServiceRunLimitTest.Platform.class,
30
+ ForegroundServiceRunLimitTest.Alarms.class
31
+ })
32
+ public class ForegroundServiceRunLimitTest {
33
+ static final int DATA = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
34
+ static final String RECEIVER = "app.notifee.core.ForegroundServiceRunLimitReceiver";
35
+ static final String ACTION = "app.notifee.core.FGS_RUN_LIMIT_EXPIRE";
36
+ Context app;
37
+ Sender sender;
38
+ final List<ServiceController<? extends ForegroundService>> controllers = new ArrayList<>();
39
+
40
+ @Before
41
+ public void setup() throws Exception {
42
+ app = RuntimeEnvironment.getApplication();
43
+ app.getApplicationInfo().flags |= ApplicationInfo.FLAG_DEBUGGABLE;
44
+ Settings.Global.putInt(app.getContentResolver(), Settings.Global.BOOT_COUNT, 42);
45
+ ServiceInfo info = new ServiceInfo();
46
+ info.packageName = app.getPackageName();
47
+ info.name = ForegroundService.class.getName();
48
+ info.processName = app.getPackageName();
49
+ Field type = ServiceInfo.class.getDeclaredField("mForegroundServiceType");
50
+ type.setAccessible(true);
51
+ type.setInt(info, DATA | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
52
+ shadowOf(app.getPackageManager()).addOrUpdateService(info);
53
+ ActivityInfo receiver = new ActivityInfo();
54
+ receiver.name = RECEIVER;
55
+ receiver.packageName = app.getPackageName();
56
+ receiver.processName = app.getPackageName();
57
+ receiver.exported = false;
58
+ receiver.enabled = true;
59
+ shadowOf(app.getPackageManager()).addOrUpdateReceiver(receiver);
60
+ app.getSystemService(android.app.NotificationManager.class)
61
+ .createNotificationChannel(
62
+ new NotificationChannel(
63
+ "alarm-poc", "PoC", android.app.NotificationManager.IMPORTANCE_LOW));
64
+ sender = new Sender(app);
65
+ ContextHolder.setApplicationContext(sender);
66
+ Alarms.reset();
67
+ Platform.reset();
68
+ Preferences.resetFaults();
69
+ }
70
+
71
+ @After
72
+ public void cleanup() throws Exception {
73
+ for (int i = controllers.size() - 1; i >= 0; i--) controllers.get(i).destroy();
74
+ ((Map<?, ?>) field(null, "sPendingStarts")).clear();
75
+ set(null, "sStopRequest", null);
76
+ set(null, "sRunGeneration", 0L);
77
+ set(null, "sInstance", null);
78
+ set(null, "sStateOwner", null);
79
+ shadowOf(Looper.getMainLooper()).idle();
80
+ ContextHolder.setApplicationContext(app);
81
+ }
82
+
83
+ static Object field(Object owner, String name) throws Exception {
84
+ Field f = ForegroundService.class.getDeclaredField(name);
85
+ f.setAccessible(true);
86
+ return f.get(owner);
87
+ }
88
+
89
+ static void set(Object owner, String name, Object value) throws Exception {
90
+ Field f = ForegroundService.class.getDeclaredField(name);
91
+ f.setAccessible(true);
92
+ f.set(owner, value);
93
+ }
94
+
95
+ Bundle bundle(String id, int type) {
96
+ Bundle b = new Bundle();
97
+ b.putString("id", id);
98
+ b.putString("title", "latest-" + id);
99
+ Bundle a = new Bundle();
100
+ a.putString("channelId", "alarm-poc");
101
+ a.putIntegerArrayList("foregroundServiceTypes", new ArrayList<>(Arrays.asList(type)));
102
+ b.putBundle("android", a);
103
+ return b;
104
+ }
105
+
106
+ Notification notification() {
107
+ return new NotificationCompat.Builder(app, "alarm-poc")
108
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
109
+ .setContentTitle("PoC")
110
+ .build();
111
+ }
112
+
113
+ void request(String id, boolean opt) throws Exception {
114
+ if (!opt) {
115
+ ForegroundService.start(id.hashCode(), notification(), bundle(id, DATA));
116
+ return;
117
+ }
118
+ // Baseline adapter intentionally runs the ordinary START when the experimental entrypoint
119
+ // is absent. RED is missing alarm/teardown behavior, never a compile error.
120
+ try {
121
+ Method m =
122
+ ForegroundService.class.getDeclaredMethod(
123
+ "startAlarmPoc",
124
+ int.class,
125
+ Notification.class,
126
+ Bundle.class,
127
+ String.class,
128
+ long.class);
129
+ m.setAccessible(true);
130
+ m.invoke(
131
+ null,
132
+ id.hashCode(),
133
+ notification(),
134
+ bundle(id, DATA),
135
+ UUID.randomUUID().toString(),
136
+ 1000L);
137
+ } catch (NoSuchMethodException absent) {
138
+ ForegroundService.start(id.hashCode(), notification(), bundle(id, DATA));
139
+ } catch (InvocationTargetException e) {
140
+ throw (RuntimeException) e.getCause();
141
+ }
142
+ }
143
+
144
+ ForegroundService create() {
145
+ ServiceController<? extends ForegroundService> c =
146
+ Preferences.enabled || Preferences.failCommit > 0
147
+ ? Robolectric.buildService(StorageService.class)
148
+ : Robolectric.buildService(ForegroundService.class);
149
+ controllers.add(c);
150
+ return c.create().get();
151
+ }
152
+
153
+ void deliver(ForegroundService s, Intent i, int id) {
154
+ Platform p = (Platform) shadowOf(s);
155
+ p.accepted = Math.max(p.accepted, id);
156
+ p.delivered = id;
157
+ s.onStartCommand(i, 0, id);
158
+ }
159
+
160
+ ForegroundService armed(String id) throws Exception {
161
+ request(id, true);
162
+ ForegroundService s = create();
163
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 1);
164
+ assertEquals(
165
+ "one actual AlarmManager registration: "
166
+ + ShadowLog.getLogsForTag("ForegroundServiceRunLimit"),
167
+ 1,
168
+ Alarms.scheduled.size());
169
+ return s;
170
+ }
171
+
172
+ Intent alarm() {
173
+ assertFalse("alarm must have actually been scheduled", Alarms.scheduled.isEmpty());
174
+ return new Intent(shadowOf(Alarms.scheduled.get(Alarms.scheduled.size() - 1)).getSavedIntent());
175
+ }
176
+
177
+ void receive(Intent i) throws Exception {
178
+ try {
179
+ ((BroadcastReceiver) Class.forName(RECEIVER).getDeclaredConstructor().newInstance())
180
+ .onReceive(app, i);
181
+ } catch (ClassNotFoundException baseline) {
182
+ /* no baseline delivery path */
183
+ }
184
+ }
185
+
186
+ void expire() throws Exception {
187
+ SystemClock.sleep(1100);
188
+ receive(alarm());
189
+ }
190
+
191
+ Platform platform(ForegroundService s) {
192
+ return (Platform) shadowOf(s);
193
+ }
194
+
195
+ void noTeardown(ForegroundService s) {
196
+ assertEquals(0, platform(s).demotions);
197
+ assertEquals(0, platform(s).stopCalls);
198
+ }
199
+
200
+ boolean marker(String marker) {
201
+ return ShadowLog.getLogsForTag("ForegroundServiceRunLimit").stream()
202
+ .anyMatch(l -> l.msg.contains(marker));
203
+ }
204
+
205
+ @Test
206
+ public void T01_defaultDisabled() throws Exception {
207
+ request("a", false);
208
+ deliver(create(), sender.starts.get(0), 1);
209
+ assertEquals(0, Alarms.requested);
210
+ }
211
+
212
+ @Test
213
+ public void T02_onlyAfterSuccessfulPromotion() throws Exception {
214
+ request("a", true);
215
+ assertEquals(0, Alarms.requested);
216
+ ForegroundService s = create();
217
+ Platform.beforePromotion = () -> assertEquals(0, Alarms.requested);
218
+ deliver(s, sender.starts.get(0), 1);
219
+ assertEquals(1, Alarms.requested);
220
+ assertEquals(1, Alarms.scheduled.size());
221
+ assertEquals(AlarmManager.ELAPSED_REALTIME_WAKEUP, Alarms.type);
222
+ assertTrue(marker("ALARM_ARMED"));
223
+ }
224
+
225
+ @Test
226
+ public void T03_deniedPromotionNeverArms() throws Exception {
227
+ request("a", true);
228
+ ForegroundService s = create();
229
+ Platform.promotionFailure = new ForegroundServiceStartNotAllowedException("denied");
230
+ deliver(s, sender.starts.get(0), 1);
231
+ assertEquals(0, Alarms.requested);
232
+ assertEquals(1, platform(s).stopCalls);
233
+ }
234
+
235
+ @Test
236
+ public void T04_elapsedClock() throws Exception {
237
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(5));
238
+ long before = SystemClock.elapsedRealtime();
239
+ armed("a");
240
+ assertEquals(before + 1000, Alarms.deadline);
241
+ }
242
+
243
+ @Test
244
+ public void T05_deepSleepCounts() throws Exception {
245
+ ForegroundService s = armed("a");
246
+ long uptime = SystemClock.uptimeMillis();
247
+ ShadowSystemClock.simulateDeepSleep(Duration.ofMillis(1100));
248
+ assertEquals(uptime, SystemClock.uptimeMillis());
249
+ receive(alarm());
250
+ assertEquals(1, platform(s).demotions);
251
+ assertEquals(1, platform(s).stopCalls);
252
+ }
253
+
254
+ @Test
255
+ public void T06_sameIdPreservesDeadlineAndLatestState() throws Exception {
256
+ ForegroundService s = armed("a");
257
+ long first = Alarms.deadline;
258
+ SystemClock.sleep(100);
259
+ request("a", false);
260
+ deliver(s, sender.starts.get(1), 2);
261
+ assertEquals(1, Alarms.requested);
262
+ assertEquals(first, Alarms.deadline);
263
+ assertEquals(
264
+ "latest-a", ((Bundle) field(null, "mCurrentNotificationBundle")).getString("title"));
265
+ expire();
266
+ assertEquals(2, platform(s).lastStopId);
267
+ assertFalse(ForegroundService.repostIfActive("a"));
268
+ }
269
+
270
+ @Test
271
+ public void T07_uniqueImmutableExplicitPendingIntent() throws Exception {
272
+ ForegroundService s = armed("a");
273
+ PendingIntent a = Alarms.scheduled.get(0);
274
+ Intent old = alarm();
275
+ ForegroundService.stop();
276
+ shadowOf(Looper.getMainLooper()).idle();
277
+ request("a", true);
278
+ deliver(s, sender.starts.get(1), 2);
279
+ PendingIntent b = Alarms.scheduled.get(1);
280
+ assertNotEquals(a, b);
281
+ assertTrue(a.isImmutable());
282
+ assertTrue(a.isBroadcast());
283
+ assertEquals(new ComponentName(app, RECEIVER), old.getComponent());
284
+ assertEquals(ACTION, old.getAction());
285
+ }
286
+
287
+ @Test
288
+ public void T08_hashCollisionCannotAliasAlarm() throws Exception {
289
+ assertEquals("Aa".hashCode(), "BB".hashCode());
290
+ ForegroundService s = armed("Aa");
291
+ PendingIntent a = Alarms.scheduled.get(0);
292
+ ForegroundService.stop();
293
+ shadowOf(Looper.getMainLooper()).idle();
294
+ request("BB", true);
295
+ deliver(s, sender.starts.get(1), 2);
296
+ assertNotEquals(a, Alarms.scheduled.get(1));
297
+ }
298
+
299
+ @Test
300
+ public void T09_invalidEnvelope() throws Exception {
301
+ ForegroundService s = armed("a");
302
+ SystemClock.sleep(1100);
303
+ receive(new Intent(alarm()).setAction("bad"));
304
+ receive(new Intent(alarm()).putExtra("token", "bad"));
305
+ receive(new Intent(alarm()).putExtra("schema", 999));
306
+ receive(new Intent(alarm()).setData(android.net.Uri.parse("bad://token")));
307
+ noTeardown(s);
308
+ receive(alarm());
309
+ assertEquals(1, platform(s).demotions);
310
+ }
311
+
312
+ @Test
313
+ public void T10_earlyDeliveryDoesNotConsume() throws Exception {
314
+ ForegroundService s = armed("a");
315
+ receive(alarm());
316
+ noTeardown(s);
317
+ assertTrue(marker("ALARM_EARLY"));
318
+ expire();
319
+ assertEquals(1, platform(s).demotions);
320
+ }
321
+
322
+ @Test
323
+ public void T11_lateDeliveryUsesCurrentOwner() throws Exception {
324
+ ForegroundService s = armed("a");
325
+ SystemClock.sleep(300000);
326
+ receive(alarm());
327
+ assertEquals(Arrays.asList("demote", "stop:1"), platform(s).order);
328
+ assertTrue(marker("ALARM_LATE"));
329
+ }
330
+
331
+ @Test
332
+ public void T12_duplicateDeliveryRetiresOnce() throws Exception {
333
+ ForegroundService s = armed("a");
334
+ Intent i = alarm();
335
+ SystemClock.sleep(1100);
336
+ receive(i);
337
+ receive(i);
338
+ assertEquals(1, platform(s).demotions);
339
+ assertEquals(1, platform(s).stopCalls);
340
+ }
341
+
342
+ @Test
343
+ public void T13_oldAlarmCannotStopSuccessor() throws Exception {
344
+ ForegroundService s = armed("a");
345
+ Intent old = alarm();
346
+ ForegroundService.stop();
347
+ shadowOf(Looper.getMainLooper()).idle();
348
+ request("b", true);
349
+ deliver(s, sender.starts.get(1), 2);
350
+ int demotions = platform(s).demotions, stops = platform(s).stopCalls;
351
+ SystemClock.sleep(1100);
352
+ receive(old);
353
+ assertEquals(demotions, platform(s).demotions);
354
+ assertEquals(stops, platform(s).stopCalls);
355
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
356
+ receive(alarm());
357
+ assertEquals(demotions + 1, platform(s).demotions);
358
+ }
359
+
360
+ @Test
361
+ public void T14_explicitStopInvalidatesBeforePostedStop() throws Exception {
362
+ ForegroundService s = armed("a");
363
+ Intent old = alarm();
364
+ ForegroundService.stop();
365
+ SystemClock.sleep(1100);
366
+ receive(old);
367
+ noTeardown(s);
368
+ assertTrue(Alarms.cancelled > 0);
369
+ }
370
+
371
+ @Test
372
+ public void T15_cancelFailureAndInFlightDelivery() throws Exception {
373
+ ForegroundService s = armed("a");
374
+ Intent old = alarm();
375
+ Alarms.cancelFailure = true;
376
+ ForegroundService.stop();
377
+ SystemClock.sleep(1100);
378
+ receive(old);
379
+ noTeardown(s);
380
+ assertTrue(marker("CANCEL_FAILED"));
381
+ }
382
+
383
+ @Test
384
+ public void T16_frameworkTimeoutInvalidatesAlarm() throws Exception {
385
+ ForegroundService s = armed("a");
386
+ Intent old = alarm();
387
+ s.onTimeout(1, DATA);
388
+ int n = platform(s).demotions;
389
+ SystemClock.sleep(1100);
390
+ receive(old);
391
+ assertEquals(n, platform(s).demotions);
392
+ assertTrue(Alarms.cancelled > 0);
393
+ }
394
+
395
+ @Test
396
+ public void T17_newInstanceSameIdRejectsOldToken() throws Exception {
397
+ ForegroundService a = armed("a");
398
+ Intent old = alarm();
399
+ controllers.get(0).destroy();
400
+ controllers.clear();
401
+ request("a", true);
402
+ ForegroundService b = create();
403
+ deliver(b, sender.starts.get(1), 1);
404
+ SystemClock.sleep(1100);
405
+ receive(old);
406
+ noTeardown(b);
407
+ receive(alarm());
408
+ assertEquals(1, platform(b).demotions);
409
+ }
410
+
411
+ @Test
412
+ public void T18_coldReceiverHasNoOwner() throws Exception {
413
+ armed("a");
414
+ Intent i = alarm();
415
+ controllers.get(0).destroy();
416
+ controllers.clear();
417
+ SystemClock.sleep(1100);
418
+ int starts = sender.starts.size();
419
+ receive(i);
420
+ assertNull(field(null, "sInstance"));
421
+ assertEquals(starts, sender.starts.size());
422
+ assertTrue(marker("NO_MATCHING_LIVE_INSTANCE"));
423
+ }
424
+
425
+ @Test
426
+ public void T19_unknownPendingStartPreserved() throws Exception {
427
+ ForegroundService s = armed("a");
428
+ sender.failure = new RuntimeException("unknown Binder reply");
429
+ assertThrows(RuntimeException.class, () -> request("pending", false));
430
+ expire();
431
+ assertEquals(1, platform(s).demotions);
432
+ assertEquals(0, platform(s).stopCalls);
433
+ assertEquals(1, ((Map<?, ?>) field(null, "sPendingStarts")).size());
434
+ assertTrue(marker("STOP_DEFERRED"));
435
+ }
436
+
437
+ @Test
438
+ public void T20_acceptedUndeliveredStartPreserved() throws Exception {
439
+ ForegroundService s = armed("a");
440
+ request("pending", false);
441
+ platform(s).accepted = 2;
442
+ expire();
443
+ assertEquals(1, platform(s).demotions);
444
+ assertEquals(0, platform(s).stopCalls);
445
+ assertEquals(1, ((Map<?, ?>) field(null, "sPendingStarts")).size());
446
+ }
447
+
448
+ @Test
449
+ public void T21_runnerCompletionCannotRetireSuccessor() throws Exception {
450
+ ForegroundService s = armed("a");
451
+ Object runner = field(s, "mRunnerToken");
452
+ Platform.duringDemotion =
453
+ () -> {
454
+ try {
455
+ request("b", false);
456
+ deliver(s, sender.starts.get(1), 2);
457
+ } catch (Exception e) {
458
+ throw new AssertionError(e);
459
+ }
460
+ };
461
+ expire();
462
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
463
+ assertNotSame(runner, field(s, "mRunnerToken"));
464
+ assertEquals(0, platform(s).stopCalls);
465
+ }
466
+
467
+ @Test
468
+ public void T22_typeReplacementInvalidatesWithoutRearm() throws Exception {
469
+ ForegroundService s = armed("a");
470
+ Intent old = alarm();
471
+ request("a", false);
472
+ Intent update = sender.starts.get(1);
473
+ update.putExtra(
474
+ "notificationBundle",
475
+ bundle("a", DATA | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK));
476
+ deliver(s, update, 2);
477
+ SystemClock.sleep(1100);
478
+ receive(old);
479
+ noTeardown(s);
480
+ assertEquals(1, Alarms.requested);
481
+ assertTrue(Alarms.cancelled > 0);
482
+ }
483
+
484
+ @Test
485
+ public void T23_schedulingFailureCannotAuthorizeDelivery() throws Exception {
486
+ Alarms.scheduleFailure = true;
487
+ request("a", true);
488
+ ForegroundService s = create();
489
+ deliver(s, sender.starts.get(0), 1);
490
+ assertEquals(1, Alarms.requested);
491
+ assertEquals(0, Alarms.scheduled.size());
492
+ assertTrue(marker("SCHEDULE_FAILED"));
493
+ SystemClock.sleep(1100);
494
+ receive(new Intent(shadowOf(Alarms.lastRequested).getSavedIntent()));
495
+ noTeardown(s);
496
+ }
497
+
498
+ @Test
499
+ public void T24_corruptLedgerCannotAuthorizeDelivery() throws Exception {
500
+ ForegroundService s = armed("a");
501
+ Intent i = alarm();
502
+ app.getSharedPreferences("notifee-fgs-run-limit", 0)
503
+ .edit()
504
+ .putString(recordKey(ticket(s)), "corrupt")
505
+ .commit();
506
+ SystemClock.sleep(1100);
507
+ receive(i);
508
+ noTeardown(s);
509
+ assertTrue(marker("LEDGER_INVALID"));
510
+ }
511
+
512
+ @Test
513
+ public void T25_demotionFailureStillAttemptsConditionalStopOnce() throws Exception {
514
+ ForegroundService s = armed("a");
515
+ Platform.demotionFailure = true;
516
+ platform(s).stopResult = false;
517
+ expire();
518
+ receive(alarm());
519
+ assertEquals(1, platform(s).demotions);
520
+ assertEquals(1, platform(s).stopCalls);
521
+ assertTrue(marker("DEMOTION_FAILED"));
522
+ assertTrue(marker("STOP_REFUSED"));
523
+ assertNotNull(field(s, "mRunnerToken"));
524
+ assertFalse(ForegroundService.repostIfActive("a"));
525
+ }
526
+
527
+ @Test
528
+ public void T26_frameworkNewerStartRefusesStop() throws Exception {
529
+ ForegroundService s = armed("a");
530
+ platform(s).accepted = 2;
531
+ expire();
532
+ assertFalse(platform(s).stopped);
533
+ assertEquals(1, platform(s).stopCalls);
534
+ assertTrue(marker("STOP_REFUSED"));
535
+ }
536
+
537
+ @Test
538
+ public void T27_stopThrowsOutcomeIsDistinct() throws Exception {
539
+ ForegroundService s = armed("a");
540
+ Platform.stopFailure = true;
541
+ expire();
542
+ assertEquals(1, platform(s).stopCalls);
543
+ assertTrue(marker("STOP_FAILED"));
544
+ assertTrue(marker("DEMOTION_RETURNED"));
545
+ }
546
+
547
+ @Test
548
+ public void T28_noReactContextOrContextHolderRequired() throws Exception {
549
+ ForegroundService s = armed("a");
550
+ ContextHolder.setApplicationContext(null);
551
+ expire();
552
+ assertEquals(1, platform(s).demotions);
553
+ assertTrue(platform(s).stopped);
554
+ }
555
+
556
+ @Test
557
+ public void T29_receiverNeverColdStartsService() throws Exception {
558
+ armed("a");
559
+ Intent i = alarm();
560
+ set(null, "sInstance", null);
561
+ set(null, "sStateOwner", null);
562
+ int starts = sender.starts.size();
563
+ SystemClock.sleep(1100);
564
+ receive(i);
565
+ assertEquals(starts, sender.starts.size());
566
+ assertTrue(marker("NO_MATCHING_LIVE_INSTANCE"));
567
+ }
568
+
569
+ @Test
570
+ public void T30_voluntaryExpiryDoesNotPublishFgTimeout() throws Exception {
571
+ ForegroundService s = armed("a");
572
+ List<Object> events = new ArrayList<>();
573
+ try (org.mockito.MockedStatic<EventBus> bus = org.mockito.Mockito.mockStatic(EventBus.class)) {
574
+ bus.when(() -> EventBus.post(org.mockito.ArgumentMatchers.any()))
575
+ .thenAnswer(
576
+ i -> {
577
+ events.add(i.getArgument(0));
578
+ return null;
579
+ });
580
+ expire();
581
+ assertTrue(events.isEmpty());
582
+ assertEquals(1, platform(s).demotions);
583
+ }
584
+ }
585
+
586
+ @Test
587
+ public void auditBootChangeCannotDemote() throws Exception {
588
+ ForegroundService s = armed("a");
589
+ Settings.Global.putInt(app.getContentResolver(), Settings.Global.BOOT_COUNT, 43);
590
+ expire();
591
+ noTeardown(s);
592
+ }
593
+
594
+ @Test
595
+ public void auditProcessIncarnationIsNotRestoredFromDisk() throws Exception {
596
+ ForegroundService s = armed("a");
597
+ Intent i = alarm();
598
+ SystemClock.sleep(1100);
599
+ String wire = i.getStringExtra("record");
600
+ String other = new org.json.JSONObject(wire).put("process", "previous-process").toString();
601
+ app.getSharedPreferences("notifee-fgs-run-limit", 0)
602
+ .edit()
603
+ .putString(recordKey(ticket(s)), other)
604
+ .commit();
605
+ receive(new Intent(i).putExtra("record", other));
606
+ noTeardown(s);
607
+ // Even a matching persisted prior-process record cannot create a native ticket.
608
+ set(s, "mAlarmPoc", null);
609
+ receive(new Intent(i).putExtra("record", other));
610
+ noTeardown(s);
611
+ }
612
+
613
+ @Test
614
+ public void auditEpochFenceIndependentOfEnvelope() throws Exception {
615
+ ForegroundService s = armed("a");
616
+ Object ticket = field(s, "mAlarmPoc");
617
+ Intent update = new Intent(sender.starts.get(0));
618
+ update.removeExtra("notifee.foregroundServiceStartRequestId");
619
+ update.putExtra(
620
+ "notificationBundle",
621
+ bundle("a", DATA | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK));
622
+ deliver(s, update, 2);
623
+ // Adversarial fixture presents an old authentic ticket after epoch replacement. The native
624
+ // epoch fence must still refuse it, even with valid token, boot, generation and ledger.
625
+ Field valid = ticket.getClass().getDeclaredField("valid");
626
+ valid.setAccessible(true);
627
+ valid.set(ticket, true);
628
+ Field wire = ticket.getClass().getDeclaredField("wire");
629
+ wire.setAccessible(true);
630
+ app.getSharedPreferences("notifee-fgs-run-limit", 0)
631
+ .edit()
632
+ .putString(
633
+ recordKey((ForegroundServiceRunLimitScheduler.Ticket) ticket),
634
+ (String) wire.get(ticket))
635
+ .putString(stateKey((ForegroundServiceRunLimitScheduler.Ticket) ticket), "ARMED")
636
+ .commit();
637
+ set(s, "mAlarmPoc", ticket);
638
+ // Preserve the exact dataSync state to isolate epoch identity from the type guard.
639
+ ForegroundService.mCurrentForegroundServiceType = DATA;
640
+ expire();
641
+ noTeardown(s);
642
+ }
643
+
644
+ @Test
645
+ public void auditOptInCannotAttachToExistingRun() throws Exception {
646
+ request("a", false);
647
+ ForegroundService s = create();
648
+ deliver(s, sender.starts.get(0), 1);
649
+ assertThrows(IllegalStateException.class, () -> request("a", true));
650
+ assertEquals(0, Alarms.requested);
651
+ noTeardown(s);
652
+ }
653
+
654
+ @Test
655
+ public void auditMixedInitialTypeRejected() throws Exception {
656
+ request("a", true);
657
+ Intent i = sender.starts.get(0);
658
+ i.putExtra(
659
+ "notificationBundle",
660
+ bundle("a", DATA | ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK));
661
+ deliver(create(), i, 1);
662
+ assertEquals(0, Alarms.requested);
663
+ assertTrue(marker("ARM_REJECTED"));
664
+ }
665
+
666
+ @Test
667
+ @Config(sdk = 34)
668
+ public void auditApi34CannotOptIn() throws Exception {
669
+ assertThrows(IllegalStateException.class, () -> request("a", true));
670
+ assertEquals(0, Alarms.requested);
671
+ }
672
+
673
+ @Test
674
+ public void auditExportedOrOtherProcessReceiverCannotOptIn() throws Exception {
675
+ ActivityInfo receiver =
676
+ app.getPackageManager().getReceiverInfo(new ComponentName(app, RECEIVER), 0);
677
+ receiver.exported = true;
678
+ shadowOf(app.getPackageManager()).addOrUpdateReceiver(receiver);
679
+ assertThrows(IllegalStateException.class, () -> request("a", true));
680
+ receiver.exported = false;
681
+ receiver.processName = app.getPackageName() + ":other";
682
+ shadowOf(app.getPackageManager()).addOrUpdateReceiver(receiver);
683
+ assertThrows(IllegalStateException.class, () -> request("a", true));
684
+ assertEquals(0, Alarms.requested);
685
+ }
686
+
687
+ @Test
688
+ public void auditNonDebugCannotOptIn() throws Exception {
689
+ app.getApplicationInfo().flags &= ~ApplicationInfo.FLAG_DEBUGGABLE;
690
+ assertThrows(IllegalStateException.class, () -> request("a", true));
691
+ assertEquals(0, Alarms.requested);
692
+ }
693
+
694
+ @Test
695
+ public void auditIncompleteArmingLedgerIsNotAuthority() throws Exception {
696
+ ForegroundService s = armed("a");
697
+ app.getSharedPreferences("notifee-fgs-run-limit", 0)
698
+ .edit()
699
+ .putString(stateKey(ticket(s)), "ARMING")
700
+ .commit();
701
+ expire();
702
+ noTeardown(s);
703
+ assertTrue(marker("LEDGER_INVALID"));
704
+ }
705
+
706
+ @Test
707
+ public void auditStopReplyCannotRetireReentrantSuccessor() throws Exception {
708
+ ForegroundService s = armed("a");
709
+ Platform.duringStop =
710
+ () -> {
711
+ try {
712
+ request("b", false);
713
+ deliver(s, sender.starts.get(1), 2);
714
+ } catch (Exception e) {
715
+ throw new AssertionError(e);
716
+ }
717
+ };
718
+ expire();
719
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
720
+ assertFalse((boolean) field(s, "mStopInitiated"));
721
+ assertNotNull(field(s, "mRunnerToken"));
722
+ }
723
+
724
+ public static final class Runners {
725
+ final List<app.notifee.core.event.ForegroundServiceEvent> events = new ArrayList<>();
726
+
727
+ @org.greenrobot.eventbus.Subscribe
728
+ public void on(app.notifee.core.event.ForegroundServiceEvent event) {
729
+ events.add(event);
730
+ }
731
+ }
732
+
733
+ @Test
734
+ public void auditRunnerCompletionDuringExpiryAndAfterSuccessor() throws Exception {
735
+ Runners runners = new Runners();
736
+ EventBus.register(runners);
737
+ try {
738
+ ForegroundService s = armed("a");
739
+ assertEquals(1, runners.events.size());
740
+ Platform.duringDemotion = () -> runners.events.get(0).setCompletionResult();
741
+ expire();
742
+ assertEquals(1, platform(s).demotions);
743
+ request("b", false);
744
+ deliver(s, sender.starts.get(1), 2);
745
+ Object successorRunner = field(s, "mRunnerToken");
746
+ runners.events.get(0).setCompletionResult();
747
+ assertSame(successorRunner, field(s, "mRunnerToken"));
748
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
749
+ } finally {
750
+ EventBus.unregister(runners);
751
+ }
752
+ }
753
+
754
+ @Test
755
+ public void auditRunnerCompletionBeforeExpiryInvalidatesAlarm() throws Exception {
756
+ Runners runners = new Runners();
757
+ EventBus.register(runners);
758
+ try {
759
+ ForegroundService s = armed("a");
760
+ Intent i = alarm();
761
+ runners.events.get(0).setCompletionResult();
762
+ int n = platform(s).demotions;
763
+ SystemClock.sleep(1100);
764
+ receive(i);
765
+ assertEquals(n, platform(s).demotions);
766
+ assertEquals(0, platform(s).stopCalls);
767
+ assertTrue(Alarms.cancelled > 0);
768
+ } finally {
769
+ EventBus.unregister(runners);
770
+ }
771
+ }
772
+
773
+ @Test
774
+ public void auditReentrantDuplicateDeliveryDuringDemotion() throws Exception {
775
+ ForegroundService s = armed("a");
776
+ Intent i = alarm();
777
+ Platform.duringDemotion =
778
+ () -> {
779
+ try {
780
+ receive(i);
781
+ } catch (Exception e) {
782
+ throw new AssertionError(e);
783
+ }
784
+ };
785
+ expire();
786
+ assertEquals(1, platform(s).demotions);
787
+ assertEquals(1, platform(s).stopCalls);
788
+ }
789
+
790
+ @Test
791
+ public void auditNewPendingStartDuringDemotion() throws Exception {
792
+ ForegroundService s = armed("a");
793
+ Platform.duringDemotion =
794
+ () -> {
795
+ try {
796
+ request("b", false);
797
+ } catch (Exception e) {
798
+ throw new AssertionError(e);
799
+ }
800
+ };
801
+ expire();
802
+ assertEquals(0, platform(s).stopCalls);
803
+ assertEquals(1, ((Map<?, ?>) field(null, "sPendingStarts")).size());
804
+ }
805
+
806
+ @Test
807
+ public void auditStorageFailureBeforeSchedule() throws Exception {
808
+ Preferences.failCommit = 1;
809
+ request("a", true);
810
+ ForegroundService s = create();
811
+ deliver(s, sender.starts.get(0), 1);
812
+ assertEquals(0, Alarms.requested);
813
+ assertTrue(marker("STORAGE_FAILED"));
814
+ noTeardown(s);
815
+ }
816
+
817
+ @Test
818
+ public void auditStorageFailureAfterSchedule() throws Exception {
819
+ Preferences.failCommit = 2;
820
+ request("a", true);
821
+ ForegroundService s = create();
822
+ deliver(s, sender.starts.get(0), 1);
823
+ assertEquals(
824
+ ShadowLog.getLogsForTag("ForegroundServiceRunLimit").toString(),
825
+ 1,
826
+ Alarms.scheduled.size());
827
+ assertTrue(Alarms.cancelled > 0);
828
+ SystemClock.sleep(1100);
829
+ receive(alarm());
830
+ noTeardown(s);
831
+ assertTrue(marker("STORAGE_FAILED"));
832
+ }
833
+
834
+ @Test
835
+ public void auditStorageReadThrows() throws Exception {
836
+ Preferences.enabled = true;
837
+ ForegroundService s = armed("a");
838
+ Preferences.readFailure = true;
839
+ expire();
840
+ noTeardown(s);
841
+ assertTrue(marker("STORAGE_FAILED"));
842
+ }
843
+
844
+ @Test
845
+ public void auditDeathDuringArmingCannotAuthorizeColdDelivery() throws Exception {
846
+ Alarms.afterSchedule =
847
+ () -> {
848
+ controllers.get(0).destroy();
849
+ controllers.clear();
850
+ };
851
+ request("a", true);
852
+ ForegroundService s = create();
853
+ deliver(s, sender.starts.get(0), 1);
854
+ assertEquals(
855
+ ShadowLog.getLogsForTag("ForegroundServiceRunLimit").toString(),
856
+ 1,
857
+ Alarms.scheduled.size());
858
+ assertTrue(Alarms.cancelled > 0);
859
+ SystemClock.sleep(1100);
860
+ receive(alarm());
861
+ noTeardown(s);
862
+ assertTrue(marker("NO_MATCHING_LIVE_INSTANCE"));
863
+ }
864
+
865
+ ForegroundServiceRunLimitScheduler.Ticket ticket(ForegroundService s) throws Exception {
866
+ return (ForegroundServiceRunLimitScheduler.Ticket) field(s, "mAlarmPoc");
867
+ }
868
+
869
+ // Baseline adapter changes only the address, never the expected ownership behavior.
870
+ String recordKey(ForegroundServiceRunLimitScheduler.Ticket t) throws Exception {
871
+ try {
872
+ Field key = t.getClass().getDeclaredField("recordKey");
873
+ key.setAccessible(true);
874
+ return (String) key.get(t);
875
+ } catch (NoSuchFieldException baseline) {
876
+ return "record";
877
+ }
878
+ }
879
+
880
+ String stateKey(ForegroundServiceRunLimitScheduler.Ticket t) throws Exception {
881
+ String key = recordKey(t);
882
+ return key.equals("record") ? "state" : key + "/state";
883
+ }
884
+
885
+ SharedPreferences store() {
886
+ return app.getSharedPreferences(ForegroundServiceRunLimitScheduler.PREFS, 0);
887
+ }
888
+
889
+ // Fixed-slot contract: absence on the preimage is a behavioral no-op, never a compile RED.
890
+ void collect(Context context) throws Exception {
891
+ try {
892
+ Method method =
893
+ ForegroundServiceRunLimitScheduler.class.getDeclaredMethod(
894
+ "collectOrphans", Context.class);
895
+ method.setAccessible(true);
896
+ method.invoke(null, context);
897
+ } catch (NoSuchMethodException preimage) {
898
+ // The preimage retains every orphan.
899
+ } catch (InvocationTargetException failure) {
900
+ throw new AssertionError(
901
+ "cleanup must report storage failure without throwing", failure.getCause());
902
+ }
903
+ }
904
+
905
+ String gcSeed(int slot, String process, int boot, String state) throws Exception {
906
+ String token = UUID.randomUUID().toString();
907
+ String key = "ticket/" + process + "/" + token;
908
+ String wire =
909
+ new org.json.JSONObject()
910
+ .put("schema", 1)
911
+ .put("boot", boot)
912
+ .put("process", process)
913
+ .put("instance", UUID.randomUUID().toString())
914
+ .put("run", "orphan")
915
+ .put("epoch", UUID.randomUUID().toString())
916
+ .put("generation", 0)
917
+ .put("runGeneration", 0)
918
+ .put("token", token)
919
+ .put("firstSuccessfulPromotionElapsed", 1)
920
+ .put("deadlineElapsed", 1001)
921
+ .put("type", DATA)
922
+ .toString();
923
+ store()
924
+ .edit()
925
+ .putString("gc/slot/" + slot, key)
926
+ .putString(key, wire)
927
+ .putString(key + "/state", state)
928
+ .commit();
929
+ return key;
930
+ }
931
+
932
+ String gcOrphan(int slot) throws Exception {
933
+ return gcSeed(slot, UUID.randomUUID().toString(), 42, "ARMED");
934
+ }
935
+
936
+ void collectCycle(Context context) throws Exception {
937
+ for (int n = 0; n < 4; n++) collect(context);
938
+ }
939
+
940
+ @Test
941
+ public void GC_T01_noOrphanNoEffect() throws Exception {
942
+ Map<String, ?> before = new HashMap<>(store().getAll());
943
+ collectCycle(app);
944
+ assertEquals(before, store().getAll());
945
+ }
946
+
947
+ @Test
948
+ public void GC_T02_provenOrphanRemoved() throws Exception {
949
+ String key = gcOrphan(0);
950
+ collectCycle(app);
951
+ assertFalse("proven orphan must be removed", store().contains(key));
952
+ assertFalse(store().contains(key + "/state"));
953
+ }
954
+
955
+ @Test
956
+ public void GC_T03_currentTicketPreserved() throws Exception {
957
+ ForegroundService s = armed("a");
958
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
959
+ collectCycle(app);
960
+ assertTrue(ForegroundServiceRunLimitScheduler.ledgerMatches(a));
961
+ assertFalse(shadowOf(a.pendingIntent).isCanceled());
962
+ expire();
963
+ assertEquals(1, platform(s).demotions);
964
+ }
965
+
966
+ @Test
967
+ public void GC_T04_cleanupAPreservesB() throws Exception {
968
+ ForegroundService s = armed("a");
969
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
970
+ promoteSuccessor(s).run();
971
+ store()
972
+ .edit()
973
+ .putString("gc/slot/10", a.recordKey)
974
+ .putString(a.recordKey, a.wire)
975
+ .putString(a.stateKey, "ARMED")
976
+ .commit();
977
+ collectCycle(app);
978
+ assertFalse(store().contains(a.recordKey));
979
+ assertSuccessorExpires(s, a);
980
+ }
981
+
982
+ @Test
983
+ public void GC_T05_reentrantBAtRemove() throws Exception {
984
+ Preferences.enabled = true;
985
+ ForegroundService s = armed("a");
986
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
987
+ String key = gcOrphan(1);
988
+ Preferences.beforeRemove = promoteSuccessor(s);
989
+ collectCycle(s);
990
+ assertNull("removal witness must execute", Preferences.beforeRemove);
991
+ assertFalse(store().contains(key));
992
+ assertSuccessorExpires(s, a);
993
+ }
994
+
995
+ @Test
996
+ public void GC_T06_ambiguousRetained() throws Exception {
997
+ String key = gcSeed(0, UUID.randomUUID().toString(), 42, "UNKNOWN");
998
+ String wire = store().getString(key, null);
999
+ collectCycle(app);
1000
+ assertEquals(wire, store().getString(key, null));
1001
+ }
1002
+
1003
+ @Test
1004
+ public void GC_T07_corruptHasNoAuthority() throws Exception {
1005
+ String key = gcOrphan(0);
1006
+ store().edit().putString(key, "{corrupt").commit();
1007
+ collectCycle(app);
1008
+ assertEquals("{corrupt", store().getString(key, null));
1009
+ assertTrue(sender.starts.isEmpty());
1010
+ }
1011
+
1012
+ @Test
1013
+ public void GC_T08_priorProcessRemoved() throws Exception {
1014
+ String key = gcSeed(0, UUID.randomUUID().toString(), 42, "ARMED");
1015
+ collectCycle(app);
1016
+ assertFalse(store().contains(key));
1017
+ }
1018
+
1019
+ @Test
1020
+ public void GC_T09_priorBootRemoved() throws Exception {
1021
+ String key = gcSeed(0, UUID.randomUUID().toString(), 41, "ARMED");
1022
+ collectCycle(app);
1023
+ assertFalse(store().contains(key));
1024
+ }
1025
+
1026
+ @Test
1027
+ public void GC_T10_armingCompleteRemovedIncompleteRetained() throws Exception {
1028
+ String full = gcSeed(0, UUID.randomUUID().toString(), 42, "ARMING");
1029
+ String partial = gcSeed(1, UUID.randomUUID().toString(), 42, "ARMING");
1030
+ store().edit().putString(partial, "{}").commit();
1031
+ collectCycle(app);
1032
+ assertFalse(store().contains(full));
1033
+ assertEquals("{}", store().getString(partial, null));
1034
+ }
1035
+
1036
+ @Test
1037
+ public void GC_T11_failedScheduleResidueRemoved() throws Exception {
1038
+ Preferences.enabled = true;
1039
+ request("a", true);
1040
+ ForegroundService s = create();
1041
+ Alarms.afterSchedule =
1042
+ () -> {
1043
+ Alarms.afterSchedule = null;
1044
+ Preferences.commitThrows = true;
1045
+ };
1046
+ deliver(s, sender.starts.get(0), 1);
1047
+ String key = store().getString("gc/slot/0", null);
1048
+ assertNotNull("failed scheduling must remain indexed", key);
1049
+ Preferences.commitThrows = false;
1050
+ collectCycle(s);
1051
+ assertFalse(store().contains(key));
1052
+ receive(alarm());
1053
+ noTeardown(s);
1054
+ }
1055
+
1056
+ @Test
1057
+ public void GC_T12_duplicateHarmless() throws Exception {
1058
+ String key = gcOrphan(0);
1059
+ collectCycle(app);
1060
+ Map<String, ?> before = new HashMap<>(store().getAll());
1061
+ collectCycle(app);
1062
+ assertEquals(before, store().getAll());
1063
+ assertFalse(store().contains(key));
1064
+ }
1065
+
1066
+ @Test
1067
+ public void GC_T13_readFailureRetains() throws Exception {
1068
+ Preferences.enabled = true;
1069
+ ForegroundService s = create();
1070
+ String key = gcOrphan(0);
1071
+ Preferences.readFailure = true;
1072
+ collectCycle(s);
1073
+ Preferences.readFailure = false;
1074
+ assertTrue(store().contains(key));
1075
+ assertTrue(marker("GC_RESULT"));
1076
+ }
1077
+
1078
+ @Test
1079
+ public void GC_T14_commitFailureRetainsAndReports() throws Exception {
1080
+ Preferences.enabled = true;
1081
+ ForegroundService s = create();
1082
+ String key = gcOrphan(0);
1083
+ Preferences.commitThrows = true;
1084
+ collectCycle(s);
1085
+ Preferences.commitThrows = false;
1086
+ assertTrue(store().contains(key));
1087
+ assertTrue(marker("GC_RESULT"));
1088
+ collectCycle(s);
1089
+ assertFalse(store().contains(key));
1090
+ }
1091
+
1092
+ @Test
1093
+ public void GC_T15_boundedEight() throws Exception {
1094
+ List<String> keys = new ArrayList<>();
1095
+ for (int n = 0; n < 32; n++) keys.add(gcOrphan(n));
1096
+ collect(app);
1097
+ long removed = keys.stream().filter(k -> !store().contains(k)).count();
1098
+ assertEquals("exactly eight slots per invocation", 8, removed);
1099
+ }
1100
+
1101
+ @Test
1102
+ public void GC_T16_moreThanBatchEventuallyRemoved() throws Exception {
1103
+ List<String> keys = new ArrayList<>();
1104
+ for (int n = 0; n < 32; n++) keys.add(gcOrphan(n));
1105
+ collectCycle(app);
1106
+ for (String key : keys) assertFalse(store().contains(key));
1107
+ }
1108
+
1109
+ @Test
1110
+ public void GC_T17_noGcInTimeout() throws Exception {
1111
+ ForegroundService s = armed("a");
1112
+ String key = gcOrphan(10);
1113
+ ShadowLog.clear();
1114
+ s.onTimeout(1, DATA);
1115
+ assertTrue(store().contains(key));
1116
+ assertFalse(marker("GC_RESULT"));
1117
+ }
1118
+
1119
+ @Test
1120
+ public void GC_T18_foreignAndLegacyKeysUntouched() throws Exception {
1121
+ store()
1122
+ .edit()
1123
+ .putString("foreign", "payload")
1124
+ .putString("record", "legacy")
1125
+ .putString("gc/slot/0", "foreign")
1126
+ .commit();
1127
+ collectCycle(app);
1128
+ assertEquals("payload", store().getString("foreign", null));
1129
+ assertEquals("legacy", store().getString("record", null));
1130
+ }
1131
+
1132
+ @Test
1133
+ public void GC_T19_noPendingIntentCancellation() throws Exception {
1134
+ ForegroundService s = armed("a");
1135
+ gcOrphan(10);
1136
+ int cancelled = Alarms.cancelled;
1137
+ collectCycle(app);
1138
+ assertEquals(cancelled, Alarms.cancelled);
1139
+ assertFalse(shadowOf(ticket(s).pendingIntent).isCanceled());
1140
+ }
1141
+
1142
+ @Test
1143
+ public void GC_T20_successorFullExpiry() throws Exception {
1144
+ ForegroundService s = armed("a");
1145
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1146
+ promoteSuccessor(s).run();
1147
+ String key = gcOrphan(10);
1148
+ collectCycle(app);
1149
+ assertFalse(store().contains(key));
1150
+ assertSuccessorExpires(s, a);
1151
+ }
1152
+
1153
+ @Test
1154
+ public void GC_auditFullAmbiguousIndexRefusesOnlyNewProtection() throws Exception {
1155
+ for (int n = 0; n < 32; n++) gcSeed(n, UUID.randomUUID().toString(), 42, "UNKNOWN");
1156
+ Map<String, ?> before = new HashMap<>(store().getAll());
1157
+ request("a", true);
1158
+ ForegroundService s = create();
1159
+ deliver(s, sender.starts.get(0), 1);
1160
+ assertEquals(before, store().getAll());
1161
+ assertEquals(0, Alarms.requested);
1162
+ assertNull(ticket(s));
1163
+ assertTrue(platform(s).foreground);
1164
+ noTeardown(s);
1165
+ assertTrue(marker("GC_CAPACITY"));
1166
+ }
1167
+
1168
+ @Test
1169
+ public void GC_auditReentrantBAtCommitKeepsIndexAndExpiry() throws Exception {
1170
+ Preferences.enabled = true;
1171
+ ForegroundService s = armed("a");
1172
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1173
+ String key = gcOrphan(10);
1174
+ Preferences.beforeCommit = promoteSuccessor(s);
1175
+ collectCycle(s);
1176
+ assertNull(Preferences.beforeCommit);
1177
+ assertFalse(store().contains(key));
1178
+ assertTrue(store().getAll().containsValue(ticket(s).recordKey));
1179
+ assertSuccessorExpires(s, a);
1180
+ }
1181
+
1182
+ @Test
1183
+ public void GC_auditWrongTypeIndexAndUnindexedRetained() throws Exception {
1184
+ String unindexed = gcOrphan(0);
1185
+ store().edit().putInt("gc/slot/0", 7).commit();
1186
+ collectCycle(app);
1187
+ assertTrue(store().contains(unindexed));
1188
+ assertEquals(7, store().getInt("gc/slot/0", 0));
1189
+ assertTrue(sender.starts.isEmpty());
1190
+ }
1191
+
1192
+ @Test
1193
+ public void GC_auditFractionalAndNoncanonicalRecordsAreAmbiguous() throws Exception {
1194
+ String[] fields = {
1195
+ "boot", "generation", "runGeneration", "deadlineElapsed", "instance", "epoch"
1196
+ };
1197
+ for (int slot = 0; slot < fields.length; slot++) {
1198
+ String key = gcOrphan(slot);
1199
+ org.json.JSONObject wire = new org.json.JSONObject(store().getString(key, null));
1200
+ wire.put(fields[slot], slot < 4 ? 42.5 : "------------------------------------");
1201
+ store().edit().putString(key, wire.toString()).commit();
1202
+ }
1203
+ Map<String, ?> before = new HashMap<>(store().getAll());
1204
+ collectCycle(app);
1205
+ assertEquals("ambiguous numbers and UUIDs must be retained", before, store().getAll());
1206
+ }
1207
+
1208
+ @Test
1209
+ public void GC_auditAnotherConfiguredServiceProcessIsNotPreviousIncarnation() throws Exception {
1210
+ String key = gcOrphan(0);
1211
+ ServiceInfo service =
1212
+ app.getPackageManager().getServiceInfo(new ComponentName(app, ForegroundService.class), 0);
1213
+ service.processName = app.getPackageName() + ":remote";
1214
+ shadowOf(app.getPackageManager()).addOrUpdateService(service);
1215
+ collectCycle(app);
1216
+ assertTrue(
1217
+ "another potentially live service process is not a proven orphan", store().contains(key));
1218
+ }
1219
+
1220
+ @Test
1221
+ public void GC_auditActiveTicketSurvivesChangedBootAndExpiredAge() throws Exception {
1222
+ ForegroundService s = armed("a");
1223
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1224
+ SystemClock.sleep(10000000);
1225
+ Settings.Global.putInt(app.getContentResolver(), Settings.Global.BOOT_COUNT, 43);
1226
+ collectCycle(app);
1227
+ assertTrue(ForegroundServiceRunLimitScheduler.ledgerMatches(a));
1228
+ assertFalse(shadowOf(a.pendingIntent).isCanceled());
1229
+ noTeardown(s);
1230
+ }
1231
+
1232
+ @Test
1233
+ public void GC_auditRepeatedCancelledRunsReuseFixedSlots() throws Exception {
1234
+ ForegroundService s = create();
1235
+ for (int n = 0; n < 65; n++) {
1236
+ request("a", true);
1237
+ deliver(s, sender.starts.get(sender.starts.size() - 1), n + 1);
1238
+ assertNotNull(ticket(s));
1239
+ ForegroundService.stop();
1240
+ shadowOf(Looper.getMainLooper()).idle();
1241
+ }
1242
+ assertEquals(65, Alarms.requested);
1243
+ assertEquals(1, store().getAll().size()); // reusable index slot only; no orphan growth.
1244
+ }
1245
+
1246
+ @Test
1247
+ public void auditOptedSameIdCannotRearmOrExtend() throws Exception {
1248
+ ForegroundService s = armed("a");
1249
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1250
+ SystemClock.sleep(100);
1251
+ try {
1252
+ request("a", true);
1253
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 2);
1254
+ } catch (IllegalStateException expectedAdmissionRejection) {
1255
+ // Ordinary updates remain allowed; this explicit new diagnostic arm is rejected.
1256
+ }
1257
+ assertEquals("same-ID opt-in must not schedule a second alarm", 1, Alarms.requested);
1258
+ assertSame(a, ticket(s));
1259
+ assertEquals(a.deadline, ticket(s).deadline);
1260
+ expire();
1261
+ assertEquals(1, platform(s).demotions);
1262
+ }
1263
+
1264
+ Runnable promoteSuccessor(ForegroundService s) {
1265
+ return () -> {
1266
+ try {
1267
+ ForegroundService.stop();
1268
+ shadowOf(Looper.getMainLooper()).idle();
1269
+ request("b", true);
1270
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 2);
1271
+ } catch (Exception e) {
1272
+ throw new AssertionError(e);
1273
+ }
1274
+ };
1275
+ }
1276
+
1277
+ void assertSuccessorExpires(ForegroundService s, ForegroundServiceRunLimitScheduler.Ticket a)
1278
+ throws Exception {
1279
+ ForegroundServiceRunLimitScheduler.Ticket b = ticket(s);
1280
+ assertNotNull("B must retain live native authority", b);
1281
+ assertNotSame(a, b);
1282
+ assertNotSame(a.epoch, b.epoch);
1283
+ assertNotEquals(a.token, b.token);
1284
+ assertNotEquals(a.runGeneration, b.runGeneration);
1285
+ assertTrue(b.valid);
1286
+ assertTrue(b.scheduled);
1287
+ assertTrue(platform(s).foreground);
1288
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
1289
+ assertEquals(b.wire, store().getString(recordKey(b), null));
1290
+ assertEquals("ARMED", store().getString(stateKey(b), null));
1291
+ assertTrue(ForegroundServiceRunLimitScheduler.matches(b, alarm()));
1292
+ assertFalse(shadowOf(b.pendingIntent).isCanceled());
1293
+ assertFalse(Alarms.cancellations.contains(b.pendingIntent));
1294
+ long deadline = b.deadline;
1295
+ int requested = Alarms.requested;
1296
+ int demotions = platform(s).demotions, stops = platform(s).stopCalls;
1297
+ SystemClock.sleep(1100);
1298
+ receive(a.intent);
1299
+ assertEquals("A cannot demote B", demotions, platform(s).demotions);
1300
+ assertEquals(stops, platform(s).stopCalls);
1301
+ try (org.mockito.MockedStatic<EventBus> bus = org.mockito.Mockito.mockStatic(EventBus.class)) {
1302
+ receive(b.intent);
1303
+ bus.verifyNoInteractions();
1304
+ }
1305
+ assertEquals(demotions + 1, platform(s).demotions);
1306
+ assertEquals(stops + 1, platform(s).stopCalls);
1307
+ assertEquals(2, platform(s).lastStopId);
1308
+ assertTrue(platform(s).stopped);
1309
+ assertFalse(platform(s).foreground);
1310
+ assertFalse(b.valid);
1311
+ assertNull(ticket(s));
1312
+ assertNull(store().getString(recordKey(b), null));
1313
+ assertEquals(deadline, b.deadline);
1314
+ assertEquals(requested, Alarms.requested);
1315
+ assertTrue(marker("DEMOTION_ATTEMPTED"));
1316
+ assertTrue(marker("STOP_ACCEPTED"));
1317
+ }
1318
+
1319
+ @Test
1320
+ public void B82_W1_reviewerCleanupPreservesSuccessorLedger() throws Exception {
1321
+ Preferences.enabled = true;
1322
+ ForegroundService s = armed("a");
1323
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1324
+ Preferences.afterRead = promoteSuccessor(s);
1325
+ s.onTimeout(1, DATA);
1326
+ assertNull("read interleaving must execute", Preferences.afterRead);
1327
+ assertEquals(
1328
+ "old cleanup must preserve successor record",
1329
+ ticket(s).wire,
1330
+ store().getString(recordKey(ticket(s)), null));
1331
+ assertSuccessorExpires(s, a);
1332
+ }
1333
+
1334
+ @Test
1335
+ public void B82_W1_reviewerCleanupMustNotDisableSuccessorExpiry() throws Exception {
1336
+ Preferences.enabled = true;
1337
+ ForegroundService s = armed("a");
1338
+ Preferences.afterRead = promoteSuccessor(s);
1339
+ s.onTimeout(1, DATA);
1340
+ assertNull(Preferences.afterRead);
1341
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
1342
+ int before = platform(s).demotions;
1343
+ SystemClock.sleep(1100);
1344
+ receive(alarm());
1345
+ assertEquals("B must retain its expiry", before + 1, platform(s).demotions);
1346
+ }
1347
+
1348
+ @Test
1349
+ public void B82_W2_replacementBeforeRemoval() throws Exception {
1350
+ Preferences.enabled = true;
1351
+ ForegroundService s = armed("a");
1352
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1353
+ Preferences.beforeRemove = promoteSuccessor(s);
1354
+ s.onTimeout(1, DATA);
1355
+ assertNull(Preferences.beforeRemove);
1356
+ assertSuccessorExpires(s, a);
1357
+ }
1358
+
1359
+ @Test
1360
+ public void B82_W2_replacementDuringCommit() throws Exception {
1361
+ Preferences.enabled = true;
1362
+ ForegroundService s = armed("a");
1363
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1364
+ Preferences.beforeCommit = promoteSuccessor(s);
1365
+ s.onTimeout(1, DATA);
1366
+ assertNull(Preferences.beforeCommit);
1367
+ assertSuccessorExpires(s, a);
1368
+ }
1369
+
1370
+ @Test
1371
+ public void B82_W3_legitimateAndAbsentCleanup() throws Exception {
1372
+ ForegroundService s = armed("a");
1373
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1374
+ assertEquals(a.wire, store().getString(recordKey(a), null));
1375
+ ForegroundService.stop();
1376
+ assertNull(store().getString(recordKey(a), null));
1377
+ assertNull(store().getString(stateKey(a), null));
1378
+ ForegroundServiceRunLimitScheduler.cancel(a);
1379
+ assertNull(store().getString(recordKey(a), null));
1380
+ assertFalse(a.valid);
1381
+ }
1382
+
1383
+ @Test
1384
+ public void B82_W4_cleanupAfterSuccessor() throws Exception {
1385
+ ForegroundService s = armed("a");
1386
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1387
+ promoteSuccessor(s).run();
1388
+ ForegroundServiceRunLimitScheduler.cancel(a);
1389
+ assertSuccessorExpires(s, a);
1390
+ }
1391
+
1392
+ @Test
1393
+ public void B82_W5_duplicateCleanupWithSameRunName() throws Exception {
1394
+ ForegroundService s = armed("b");
1395
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1396
+ ForegroundService.stop();
1397
+ shadowOf(Looper.getMainLooper()).idle();
1398
+ ForegroundServiceRunLimitScheduler.cancel(a);
1399
+ request("b", true);
1400
+ deliver(s, sender.starts.get(1), 2);
1401
+ ForegroundServiceRunLimitScheduler.cancel(a);
1402
+ ForegroundServiceRunLimitScheduler.cancel(a);
1403
+ assertSuccessorExpires(s, a);
1404
+ }
1405
+
1406
+ @Test
1407
+ public void B82_W6_lateSchedulingFailure() throws Exception {
1408
+ ForegroundService s = create();
1409
+ ForegroundServiceRunLimitScheduler.Ticket[] a =
1410
+ new ForegroundServiceRunLimitScheduler.Ticket[1];
1411
+ Alarms.afterSchedule =
1412
+ () -> {
1413
+ Alarms.afterSchedule = null;
1414
+ try {
1415
+ a[0] = ticket(s);
1416
+ } catch (Exception e) {
1417
+ throw new AssertionError(e);
1418
+ }
1419
+ promoteSuccessor(s).run();
1420
+ throw new IllegalStateException("late A scheduling failure");
1421
+ };
1422
+ request("a", true);
1423
+ deliver(s, sender.starts.get(0), 1);
1424
+ assertTrue(marker("SCHEDULE_FAILED"));
1425
+ assertFalse(a[0].valid);
1426
+ assertSuccessorExpires(s, a[0]);
1427
+ }
1428
+
1429
+ @Test
1430
+ public void B82_W7_cancelFailureDuringReplacement() throws Exception {
1431
+ ForegroundService s = armed("a");
1432
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1433
+ Alarms.duringCancel = promoteSuccessor(s);
1434
+ Alarms.cancelFailure = true;
1435
+ s.onTimeout(1, DATA);
1436
+ assertTrue(marker("CANCEL_FAILED"));
1437
+ Alarms.cancelFailure = false;
1438
+ assertSuccessorExpires(s, a);
1439
+ }
1440
+
1441
+ @Test
1442
+ public void B82_W8_storageReadFailureDuringOldCleanup() throws Exception {
1443
+ Preferences.enabled = true;
1444
+ ForegroundService s = armed("a");
1445
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1446
+ promoteSuccessor(s).run();
1447
+ Preferences.readFailure = true;
1448
+ ForegroundServiceRunLimitScheduler.cancel(a);
1449
+ assertFalse(a.valid);
1450
+ assertTrue(marker("STORAGE_FAILED"));
1451
+ Preferences.readFailure = false;
1452
+ assertSuccessorExpires(s, a);
1453
+ }
1454
+
1455
+ @Test
1456
+ public void B82_W8_storageWriteFailureDuringReplacement() throws Exception {
1457
+ Preferences.enabled = true;
1458
+ ForegroundService s = armed("a");
1459
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1460
+ Preferences.beforeCommit =
1461
+ () -> {
1462
+ promoteSuccessor(s).run();
1463
+ Preferences.commitThrows = true;
1464
+ };
1465
+ s.onTimeout(1, DATA);
1466
+ assertTrue(marker("STORAGE_FAILED"));
1467
+ Preferences.commitThrows = false;
1468
+ assertSuccessorExpires(s, a);
1469
+ }
1470
+
1471
+ @Test
1472
+ public void B82_W9_corruptRecordCannotBeDeletedAsOwned() throws Exception {
1473
+ ForegroundService s = armed("a");
1474
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1475
+ store().edit().putString(recordKey(a), "corrupt").commit();
1476
+ ForegroundServiceRunLimitScheduler.cancel(a);
1477
+ assertEquals("corrupt", store().getString(recordKey(a), null));
1478
+ promoteSuccessor(s).run();
1479
+ ForegroundServiceRunLimitScheduler.cancel(a);
1480
+ assertSuccessorExpires(s, a);
1481
+ }
1482
+
1483
+ @Test
1484
+ public void B82_W10_fullExpiryAfterLateCleanup() throws Exception {
1485
+ ForegroundService s = armed("a");
1486
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1487
+ s.onTimeout(1, DATA);
1488
+ promoteSuccessor(s).run();
1489
+ ForegroundServiceRunLimitScheduler.cancel(a);
1490
+ assertSuccessorExpires(s, a);
1491
+ }
1492
+
1493
+ @Test
1494
+ public void B82_auditLateArmingCommitCannotOverwriteSuccessor() throws Exception {
1495
+ Preferences.enabled = true;
1496
+ ForegroundService s = create();
1497
+ ForegroundServiceRunLimitScheduler.Ticket[] a =
1498
+ new ForegroundServiceRunLimitScheduler.Ticket[1];
1499
+ Preferences.beforeCommit =
1500
+ () -> {
1501
+ try {
1502
+ a[0] = ticket(s);
1503
+ } catch (Exception e) {
1504
+ throw new AssertionError(e);
1505
+ }
1506
+ promoteSuccessor(s).run();
1507
+ };
1508
+ request("a", true);
1509
+ deliver(s, sender.starts.get(0), 1);
1510
+ assertNull(Preferences.beforeCommit);
1511
+ assertFalse(a[0].valid);
1512
+ assertNull(store().getString(recordKey(a[0]), null));
1513
+ assertSuccessorExpires(s, a[0]);
1514
+ }
1515
+
1516
+ @Test
1517
+ public void B82_auditLateArmedCommitCannotOverwriteSuccessor() throws Exception {
1518
+ Preferences.enabled = true;
1519
+ ForegroundService s = create();
1520
+ ForegroundServiceRunLimitScheduler.Ticket[] a =
1521
+ new ForegroundServiceRunLimitScheduler.Ticket[1];
1522
+ Alarms.afterSchedule =
1523
+ () -> {
1524
+ Alarms.afterSchedule = null;
1525
+ try {
1526
+ a[0] = ticket(s);
1527
+ } catch (Exception e) {
1528
+ throw new AssertionError(e);
1529
+ }
1530
+ Preferences.beforeCommit = promoteSuccessor(s);
1531
+ };
1532
+ request("a", true);
1533
+ deliver(s, sender.starts.get(0), 1);
1534
+ assertNull(Preferences.beforeCommit);
1535
+ assertFalse(a[0].valid);
1536
+ assertNull(store().getString(recordKey(a[0]), null));
1537
+ assertSuccessorExpires(s, a[0]);
1538
+ }
1539
+
1540
+ @Test
1541
+ public void B82_auditOwnedOrphanCleanupPreservesForeignPayload() throws Exception {
1542
+ ForegroundService s = armed("a");
1543
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1544
+ promoteSuccessor(s).run();
1545
+ ForegroundServiceRunLimitScheduler.Ticket b = ticket(s);
1546
+ // A foreign wire at A's address is corruption, not authority to remove that payload.
1547
+ store().edit().putString(recordKey(a), b.wire).putString(stateKey(a), "ARMED").commit();
1548
+ ForegroundServiceRunLimitScheduler.cancel(a);
1549
+ assertEquals(b.wire, store().getString(recordKey(a), null));
1550
+ // An actual own orphan may still be cleaned without touching the successor.
1551
+ store().edit().putString(recordKey(a), a.wire).commit();
1552
+ ForegroundServiceRunLimitScheduler.cancel(a);
1553
+ assertNull(store().getString(recordKey(a), null));
1554
+ assertNull(store().getString(stateKey(a), null));
1555
+ assertSuccessorExpires(s, a);
1556
+ }
1557
+
1558
+ @Test
1559
+ public void B82_auditWrongStorageTypeFailsClosed() throws Exception {
1560
+ ForegroundService s = armed("a");
1561
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1562
+ promoteSuccessor(s).run();
1563
+ store().edit().putInt(recordKey(a), 42).commit();
1564
+ ForegroundServiceRunLimitScheduler.cancel(a);
1565
+ assertEquals(42, store().getInt(recordKey(a), 0));
1566
+ assertTrue(marker("STORAGE_FAILED"));
1567
+ assertSuccessorExpires(s, a);
1568
+ }
1569
+
1570
+ @Test
1571
+ public void B82_auditRefusedCleanupCanRetryOnlyOwnOrphan() throws Exception {
1572
+ Preferences.enabled = true;
1573
+ ForegroundService s = armed("a");
1574
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1575
+ Preferences.failCommit = Preferences.commits + 1;
1576
+ s.onTimeout(1, DATA);
1577
+ assertEquals(a.wire, store().getString(recordKey(a), null));
1578
+ assertFalse(a.valid);
1579
+ assertTrue(marker("STORAGE_FAILED"));
1580
+ Preferences.failCommit = 0;
1581
+ promoteSuccessor(s).run();
1582
+ ForegroundServiceRunLimitScheduler.cancel(a);
1583
+ assertNull(store().getString(recordKey(a), null));
1584
+ assertSuccessorExpires(s, a);
1585
+ }
1586
+
1587
+ @Test
1588
+ public void B82_auditLegacyAndPriorProcessOrphansNeverGrantAuthority() throws Exception {
1589
+ ForegroundService s = armed("a");
1590
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1591
+ String legacy = a.wire;
1592
+ ForegroundService.stop();
1593
+ shadowOf(Looper.getMainLooper()).idle();
1594
+ String oldKey = "ticket/previous-process/old-token";
1595
+ store()
1596
+ .edit()
1597
+ .putString("record", legacy)
1598
+ .putString("state", "ARMED")
1599
+ .putString(oldKey, legacy)
1600
+ .putString(oldKey + "/state", "corrupt")
1601
+ .commit();
1602
+ set(null, "sInstance", null);
1603
+ set(null, "sStateOwner", null);
1604
+ Map<String, ?> snapshot = new HashMap<>(store().getAll());
1605
+ int starts = sender.starts.size();
1606
+ SystemClock.sleep(1100);
1607
+ receive(a.intent);
1608
+ assertEquals(starts, sender.starts.size());
1609
+ assertNull(field(null, "sInstance"));
1610
+ assertEquals(snapshot, store().getAll());
1611
+ // A fresh service needs an explicit new promoted ticket; no disk restoration occurs.
1612
+ request("b", true);
1613
+ ForegroundService b = create();
1614
+ deliver(b, sender.starts.get(1), 2);
1615
+ assertEquals(legacy, store().getString("record", null));
1616
+ assertEquals(legacy, store().getString(oldKey, null));
1617
+ ForegroundServiceRunLimitScheduler.cancel(a);
1618
+ assertEquals(legacy, store().getString("record", null));
1619
+ assertSuccessorExpires(b, a);
1620
+ assertEquals(legacy, store().getString(oldKey, null));
1621
+ }
1622
+
1623
+ public static class StorageService extends ForegroundService {
1624
+ @Override
1625
+ public SharedPreferences getSharedPreferences(String name, int mode) {
1626
+ return Preferences.wrap(super.getSharedPreferences(name, mode), name);
1627
+ }
1628
+ }
1629
+
1630
+ public static class Preferences {
1631
+ static int failCommit, commits;
1632
+ static boolean readFailure, enabled;
1633
+ static Runnable afterRead, beforeRemove, beforeCommit;
1634
+ static boolean commitThrows;
1635
+
1636
+ static void resetFaults() {
1637
+ failCommit = commits = 0;
1638
+ readFailure = enabled = false;
1639
+ afterRead = beforeRemove = beforeCommit = null;
1640
+ commitThrows = false;
1641
+ }
1642
+
1643
+ static SharedPreferences wrap(SharedPreferences prefs, String name) {
1644
+ if (!"notifee-fgs-run-limit".equals(name)
1645
+ || (failCommit == 0
1646
+ && !readFailure
1647
+ && afterRead == null
1648
+ && beforeRemove == null
1649
+ && beforeCommit == null
1650
+ && !commitThrows)) return prefs;
1651
+ SharedPreferences proxy =
1652
+ org.mockito.Mockito.mock(
1653
+ SharedPreferences.class,
1654
+ org.mockito.Mockito.withSettings()
1655
+ .defaultAnswer(
1656
+ i -> {
1657
+ if ("getString".equals(i.getMethod().getName()) && readFailure)
1658
+ throw new IllegalStateException("storage unreadable");
1659
+ if ("edit".equals(i.getMethod().getName())) {
1660
+ SharedPreferences.Editor real = prefs.edit();
1661
+ return (SharedPreferences.Editor)
1662
+ java.lang.reflect.Proxy.newProxyInstance(
1663
+ SharedPreferences.Editor.class.getClassLoader(),
1664
+ new Class<?>[] {SharedPreferences.Editor.class},
1665
+ (editorProxy, method, args) -> {
1666
+ if ("remove".equals(method.getName()) && beforeRemove != null) {
1667
+ Runnable hook = beforeRemove;
1668
+ beforeRemove = null;
1669
+ hook.run();
1670
+ }
1671
+ if ("commit".equals(method.getName())) {
1672
+ Runnable hook = beforeCommit;
1673
+ beforeCommit = null;
1674
+ if (hook != null) hook.run();
1675
+ if (commitThrows)
1676
+ throw new IllegalStateException("commit failed");
1677
+ commits++;
1678
+ if (commits == failCommit) return false;
1679
+ }
1680
+ Object result = method.invoke(real, args);
1681
+ return result instanceof SharedPreferences.Editor
1682
+ ? editorProxy
1683
+ : result;
1684
+ });
1685
+ }
1686
+ Object result = i.getMethod().invoke(prefs, i.getArguments());
1687
+ if ("getString".equals(i.getMethod().getName()) && afterRead != null) {
1688
+ Runnable hook = afterRead;
1689
+ afterRead = null;
1690
+ hook.run();
1691
+ }
1692
+ return result;
1693
+ }));
1694
+ return proxy;
1695
+ }
1696
+ }
1697
+
1698
+ @Test
1699
+ public void auditLedgerIpcCannotTransferExpiryToSuccessor() throws Exception {
1700
+ Preferences.enabled = true;
1701
+ ForegroundService s = armed("a");
1702
+ Intent old = alarm();
1703
+ Preferences.afterRead =
1704
+ () -> {
1705
+ try {
1706
+ ForegroundService.stop();
1707
+ shadowOf(Looper.getMainLooper()).idle();
1708
+ request("b", true);
1709
+ deliver(s, sender.starts.get(1), 2);
1710
+ } catch (Exception error) {
1711
+ throw new AssertionError(error);
1712
+ }
1713
+ };
1714
+ SystemClock.sleep(1100);
1715
+ receive(old);
1716
+ assertEquals("b", ForegroundService.mCurrentNotificationId);
1717
+ assertTrue(platform(s).foreground);
1718
+ assertNotNull(field(s, "mAlarmPoc"));
1719
+ }
1720
+
1721
+ @Test
1722
+ public void auditReceiverOwnerRevalidatedAfterLastLedgerRead() throws Exception {
1723
+ Preferences.enabled = true;
1724
+ ForegroundService s = armed("a");
1725
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
1726
+ int[] successorDemotions = {-1};
1727
+ // First read returns the authentic A wire. The last read returns authentic ARMED A,
1728
+ // but only after B has been created. This reaches the post-ledger ownership fence.
1729
+ Preferences.afterRead =
1730
+ () ->
1731
+ Preferences.afterRead =
1732
+ () -> {
1733
+ promoteSuccessor(s).run();
1734
+ successorDemotions[0] = platform(s).demotions;
1735
+ };
1736
+ SystemClock.sleep(1100);
1737
+ receive(a.intent);
1738
+ assertNull(Preferences.afterRead);
1739
+ assertTrue("the final-read interleaving must execute", successorDemotions[0] >= 0);
1740
+ assertEquals(
1741
+ "old expiry cannot demote B after a matching ledger snapshot",
1742
+ successorDemotions[0],
1743
+ platform(s).demotions);
1744
+ assertSuccessorExpires(s, a);
1745
+ }
1746
+
1747
+ @Test
1748
+ public void auditManifestWithoutDataSyncCannotDispatch() throws Exception {
1749
+ ServiceInfo service =
1750
+ app.getPackageManager().getServiceInfo(new ComponentName(app, ForegroundService.class), 0);
1751
+ Field type = ServiceInfo.class.getDeclaredField("mForegroundServiceType");
1752
+ type.setAccessible(true);
1753
+ type.setInt(service, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE);
1754
+ shadowOf(app.getPackageManager()).addOrUpdateService(service);
1755
+ assertThrows(IllegalStateException.class, () -> request("a", true));
1756
+ assertTrue(sender.starts.isEmpty());
1757
+ assertEquals(0, Alarms.requested);
1758
+ }
1759
+
1760
+ @Test
1761
+ public void auditPendingIntentIdentityIsUniqueWithoutCancellation() throws Exception {
1762
+ ForegroundService s = create();
1763
+ ForegroundServiceRunLimitScheduler.Request a =
1764
+ new ForegroundServiceRunLimitScheduler.Request(app, "Aa", 1000);
1765
+ ForegroundServiceRunLimitScheduler.Request b =
1766
+ new ForegroundServiceRunLimitScheduler.Request(app, "BB", 1000);
1767
+ ForegroundServiceRunLimitScheduler.Ticket first =
1768
+ new ForegroundServiceRunLimitScheduler.Ticket(
1769
+ s, new Object(), 0, 0, a, SystemClock.elapsedRealtime());
1770
+ ForegroundServiceRunLimitScheduler.Ticket second =
1771
+ new ForegroundServiceRunLimitScheduler.Ticket(
1772
+ s, new Object(), 0, 0, b, SystemClock.elapsedRealtime());
1773
+ try {
1774
+ assertTrue(ForegroundServiceRunLimitScheduler.arm(first));
1775
+ assertTrue(ForegroundServiceRunLimitScheduler.arm(second));
1776
+ assertNotEquals(first.pendingIntent, second.pendingIntent);
1777
+ assertNotEquals(first.intent.getData(), second.intent.getData());
1778
+ } finally {
1779
+ ForegroundServiceRunLimitScheduler.cancel(first);
1780
+ ForegroundServiceRunLimitScheduler.cancel(second);
1781
+ }
1782
+ }
1783
+
1784
+ Bundle publicBundle(String id, Object duration) {
1785
+ Bundle b = bundle(id, DATA);
1786
+ Bundle a = b.getBundle("android");
1787
+ a.putBoolean("asForegroundService", true);
1788
+ if (duration instanceof Double)
1789
+ a.putDouble("foregroundServiceBestEffortStopAfterMs", (Double) duration);
1790
+ else if (duration instanceof Long)
1791
+ a.putLong("foregroundServiceBestEffortStopAfterMs", (Long) duration);
1792
+ else if (duration instanceof String)
1793
+ a.putString("foregroundServiceBestEffortStopAfterMs", (String) duration);
1794
+ return b;
1795
+ }
1796
+
1797
+ void publicStart(Bundle b) {
1798
+ ForegroundService.start(b.getString("id").hashCode(), notification(), b);
1799
+ }
1800
+
1801
+ ForegroundService publicArmed(String id) {
1802
+ publicStart(publicBundle(id, 60000L));
1803
+ assertEquals("no registration before real promotion", 0, Alarms.requested);
1804
+ ForegroundService s = create();
1805
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 1);
1806
+ assertEquals("public option must reach AlarmManager", 1, Alarms.scheduled.size());
1807
+ return s;
1808
+ }
1809
+
1810
+ @Test
1811
+ public void PUB_validBridgeDoubleAndPostPromotion() throws Exception {
1812
+ publicStart(publicBundle("public", 60000.0));
1813
+ assertEquals(0, Alarms.requested);
1814
+ ForegroundService s = create();
1815
+ deliver(s, sender.starts.get(0), 1);
1816
+ assertEquals("public JS double must arm", 1, Alarms.scheduled.size());
1817
+ assertEquals(SystemClock.elapsedRealtime() + 60000, Alarms.deadline);
1818
+ }
1819
+
1820
+ @Test
1821
+ public void PUB_nativeInvalidNeverDispatchesStart() {
1822
+ for (Object value :
1823
+ new Object[] {
1824
+ Double.NaN,
1825
+ Double.POSITIVE_INFINITY,
1826
+ 60000.5,
1827
+ 0L,
1828
+ -1L,
1829
+ 59999L,
1830
+ 18000001L,
1831
+ Long.MAX_VALUE,
1832
+ "60000"
1833
+ }) {
1834
+ assertThrows(
1835
+ "invalid option: " + value,
1836
+ IllegalArgumentException.class,
1837
+ () -> publicStart(publicBundle("bad", value)));
1838
+ assertTrue(sender.starts.isEmpty());
1839
+ }
1840
+ }
1841
+
1842
+ @Test
1843
+ @Config(sdk = 34)
1844
+ public void PUB_unsupportedApiNeverDispatchesStart() {
1845
+ assertThrows(IllegalArgumentException.class, () -> publicStart(publicBundle("old", 60000L)));
1846
+ assertTrue(sender.starts.isEmpty());
1847
+ }
1848
+
1849
+ @Test
1850
+ public void PUB_requiresAsForegroundService() {
1851
+ Bundle b = publicBundle("not-fgs", 60000L);
1852
+ b.getBundle("android").putBoolean("asForegroundService", false);
1853
+ assertThrows(IllegalArgumentException.class, () -> publicStart(b));
1854
+ assertTrue(sender.starts.isEmpty());
1855
+ }
1856
+
1857
+ @Test
1858
+ public void PUB_mixedTypesNeverDispatchStart() {
1859
+ Bundle b = publicBundle("mixed", 60000L);
1860
+ b.getBundle("android")
1861
+ .putIntegerArrayList("foregroundServiceTypes", new ArrayList<>(Arrays.asList(DATA, 2)));
1862
+ assertThrows(IllegalArgumentException.class, () -> publicStart(b));
1863
+ assertTrue(sender.starts.isEmpty());
1864
+ }
1865
+
1866
+ @Test
1867
+ public void PUB_sameIdRepeatAndOmissionKeepOriginalDeadline() throws Exception {
1868
+ ForegroundService s = publicArmed("same");
1869
+ Object first = field(s, "mAlarmPoc");
1870
+ long deadline = Alarms.deadline;
1871
+ publicStart(publicBundle("same", 60000L));
1872
+ deliver(s, sender.starts.get(1), 2);
1873
+ publicStart(publicBundle("same", null));
1874
+ deliver(s, sender.starts.get(2), 3);
1875
+ assertEquals(1, Alarms.requested);
1876
+ assertEquals(deadline, Alarms.deadline);
1877
+ assertSame(first, field(s, "mAlarmPoc"));
1878
+ }
1879
+
1880
+ @Test
1881
+ public void PUB_changedDurationRejectedBeforeStart() throws Exception {
1882
+ publicArmed("same");
1883
+ assertThrows(IllegalArgumentException.class, () -> publicStart(publicBundle("same", 120000L)));
1884
+ assertEquals(1, sender.starts.size());
1885
+ assertEquals(1, Alarms.requested);
1886
+ }
1887
+
1888
+ @Test
1889
+ public void PUB_cannotEnableMidRun() throws Exception {
1890
+ publicStart(publicBundle("same", null));
1891
+ ForegroundService s = create();
1892
+ deliver(s, sender.starts.get(0), 1);
1893
+ assertThrows(IllegalArgumentException.class, () -> publicStart(publicBundle("same", 60000L)));
1894
+ assertEquals(0, Alarms.requested);
1895
+ }
1896
+
1897
+ @Test
1898
+ public void PUB_deniedPromotionNeverArms() throws Exception {
1899
+ publicStart(publicBundle("denied", 60000L));
1900
+ ForegroundService s = create();
1901
+ Platform.promotionFailure = new ForegroundServiceStartNotAllowedException("denied");
1902
+ deliver(s, sender.starts.get(0), 1);
1903
+ assertEquals(0, Alarms.requested);
1904
+ }
1905
+
1906
+ @Test
1907
+ public void PUB_gcDoesNotRunBeforePromotion() throws Exception {
1908
+ publicStart(publicBundle("latency", 60000L));
1909
+ assertTrue(
1910
+ ShadowLog.getLogsForTag("ForegroundServiceRunLimit").stream()
1911
+ .noneMatch(x -> x.msg.contains("GC_RESULT")));
1912
+ ForegroundService s = create();
1913
+ Platform.beforePromotion =
1914
+ () ->
1915
+ assertTrue(
1916
+ ShadowLog.getLogsForTag("ForegroundServiceRunLimit").stream()
1917
+ .noneMatch(x -> x.msg.contains("GC_RESULT")));
1918
+ deliver(s, sender.starts.get(0), 1);
1919
+ assertEquals(1, Alarms.scheduled.size());
1920
+ }
1921
+
1922
+ @Test
1923
+ public void PUB_releaseAppArmsWithoutDebugReflection() throws Exception {
1924
+ app.getApplicationInfo().flags &= ~ApplicationInfo.FLAG_DEBUGGABLE;
1925
+ publicArmed("release");
1926
+ }
1927
+
1928
+ @Test
1929
+ public void PUB_expiryEventUsesLatestNotificationAfterTeardownUnlocked() throws Exception {
1930
+ ForegroundService s = publicArmed("event");
1931
+ Bundle update = publicBundle("event", null);
1932
+ update.putString("title", "updated");
1933
+ publicStart(update);
1934
+ deliver(s, sender.starts.get(1), 2);
1935
+ List<app.notifee.core.event.NotificationEvent> events = new ArrayList<>();
1936
+ try (org.mockito.MockedStatic<EventBus> bus = org.mockito.Mockito.mockStatic(EventBus.class)) {
1937
+ bus.when(() -> EventBus.post(org.mockito.ArgumentMatchers.any()))
1938
+ .thenAnswer(
1939
+ i -> {
1940
+ Object event = i.getArgument(0);
1941
+ if (event instanceof app.notifee.core.event.NotificationEvent) {
1942
+ assertEquals(1, platform(s).demotions);
1943
+ assertEquals(1, platform(s).stopCalls);
1944
+ assertFalse(Thread.holdsLock(field(null, "sLifecycleLock")));
1945
+ assertFalse(Thread.holdsLock(field(null, "sLock")));
1946
+ events.add((app.notifee.core.event.NotificationEvent) event);
1947
+ }
1948
+ return null;
1949
+ });
1950
+ SystemClock.sleep(60001);
1951
+ receive(alarm());
1952
+ receive(alarm());
1953
+ assertEquals(1, events.size());
1954
+ assertEquals(10, events.get(0).getType());
1955
+ assertEquals("updated", events.get(0).getNotification().toBundle().getString("title"));
1956
+ Map<?, ?> detail = (Map<?, ?>) events.get(0).toMap().get("detail");
1957
+ Map<?, ?> meta = (Map<?, ?>) detail.get("foregroundServiceRunLimit");
1958
+ assertEquals(60000L, ((Number) meta.get("requestedDurationMs")).longValue());
1959
+ assertEquals("returned", meta.get("demotion"));
1960
+ assertEquals("accepted", meta.get("conditionalStop"));
1961
+ assertFalse(meta.containsKey("stopped"));
1962
+ assertFalse(meta.containsKey("amsState"));
1963
+ assertNull(field(s, "mRunnerToken"));
1964
+ }
1965
+ }
1966
+
1967
+ @Test
1968
+ public void PUB_eventFailureCannotPreventTeardown() throws Exception {
1969
+ ForegroundService s = publicArmed("bridge-dead");
1970
+ try (org.mockito.MockedStatic<EventBus> bus = org.mockito.Mockito.mockStatic(EventBus.class)) {
1971
+ bus.when(() -> EventBus.post(org.mockito.ArgumentMatchers.any()))
1972
+ .thenThrow(new IllegalStateException("React absent"));
1973
+ SystemClock.sleep(60001);
1974
+ receive(alarm());
1975
+ assertEquals(1, platform(s).demotions);
1976
+ assertEquals(1, platform(s).stopCalls);
1977
+ assertTrue(marker("EVENT_FAILED"));
1978
+ }
1979
+ }
1980
+
1981
+ @Test
1982
+ public void PUB_storageAndSchedulingFailureAreObservable() throws Exception {
1983
+ Alarms.scheduleFailure = true;
1984
+ publicStart(publicBundle("schedule-failed", 60000L));
1985
+ ForegroundService s = create();
1986
+ deliver(s, sender.starts.get(0), 1);
1987
+ assertTrue(marker("SCHEDULE_FAILED"));
1988
+ assertFalse(marker("ALARM_ARMED"));
1989
+ assertNull(field(s, "mAlarmPoc"));
1990
+ assertTrue(platform(s).foreground);
1991
+ }
1992
+
1993
+ @Test
1994
+ public void PUB_nativeDeadlineOverflowRejected() {
1995
+ try (org.mockito.MockedStatic<SystemClock> clock =
1996
+ org.mockito.Mockito.mockStatic(SystemClock.class)) {
1997
+ clock.when(SystemClock::elapsedRealtime).thenReturn(Long.MAX_VALUE - 100);
1998
+ assertThrows(
1999
+ IllegalArgumentException.class, () -> publicStart(publicBundle("overflow", 60000L)));
2000
+ assertTrue(sender.starts.isEmpty());
2001
+ }
2002
+ }
2003
+
2004
+ @Test
2005
+ public void PUB_gcRecognizesFullPublicRange() throws Exception {
2006
+ String key = gcOrphan(0);
2007
+ org.json.JSONObject wire = new org.json.JSONObject(store().getString(key, null));
2008
+ wire.put("deadlineElapsed", 18000001L);
2009
+ store().edit().putString(key, wire.toString()).commit();
2010
+ collectCycle(app);
2011
+ assertFalse("valid public five-hour orphan is collectible", store().contains(key));
2012
+ }
2013
+
2014
+ @Test
2015
+ public void PUB_newRunAfterRunnerCompletionGetsNewAuthority() throws Exception {
2016
+ Runners runners = new Runners();
2017
+ EventBus.register(runners);
2018
+ try {
2019
+ ForegroundService s = publicArmed("completed");
2020
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
2021
+ runners.events.get(0).setCompletionResult();
2022
+ shadowOf(Looper.getMainLooper()).idle();
2023
+ publicStart(publicBundle("next", 60000L));
2024
+ deliver(s, sender.starts.get(1), 2);
2025
+ ForegroundServiceRunLimitScheduler.Ticket b = ticket(s);
2026
+ assertNotNull("new real promotion must arm its own ticket", b);
2027
+ assertNotSame(a, b);
2028
+ assertNotEquals(a.token, b.token);
2029
+ assertNotSame(a.epoch, b.epoch);
2030
+ SystemClock.sleep(60001);
2031
+ receive(a.intent);
2032
+ assertSame(b, ticket(s));
2033
+ } finally {
2034
+ EventBus.unregister(runners);
2035
+ }
2036
+ }
2037
+
2038
+ @Test
2039
+ public void PUB_reentrantStartDuringStopCancellationSupersedesOldStop() throws Exception {
2040
+ ForegroundService s = publicArmed("stop-order");
2041
+ ForegroundServiceRunLimitScheduler.Ticket a = ticket(s);
2042
+ Alarms.duringCancel =
2043
+ () -> {
2044
+ publicStart(publicBundle("stop-order", null));
2045
+ deliver(s, sender.starts.get(1), 2);
2046
+ };
2047
+ ForegroundService.stop();
2048
+ shadowOf(Looper.getMainLooper()).idle();
2049
+ assertFalse("later accepted START must supersede an older STOP", platform(s).stopped);
2050
+ assertTrue(platform(s).foreground);
2051
+ assertEquals("stop-order", ForegroundService.mCurrentNotificationId);
2052
+ assertNotNull(field(s, "mRunnerToken"));
2053
+ assertNotSame(a.epoch, field(s, "mForegroundEpoch"));
2054
+ }
2055
+
2056
+ @Test
2057
+ public void PUB_completionCancellationCannotClearSuccessorState() throws Exception {
2058
+ Runners runners = new Runners();
2059
+ EventBus.register(runners);
2060
+ try {
2061
+ ForegroundService s = publicArmed("completion-a");
2062
+ ForegroundServiceRunLimitScheduler.Ticket[] b = {null};
2063
+ Alarms.duringCancel =
2064
+ () -> {
2065
+ ForegroundService.stop();
2066
+ shadowOf(Looper.getMainLooper()).idle();
2067
+ publicStart(publicBundle("completion-b", 60000L));
2068
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 2);
2069
+ try {
2070
+ b[0] = ticket(s);
2071
+ assertNotNull(b[0]);
2072
+ } catch (Exception failure) {
2073
+ throw new AssertionError(failure);
2074
+ }
2075
+ };
2076
+ runners.events.get(0).setCompletionResult();
2077
+ shadowOf(Looper.getMainLooper()).idle();
2078
+ assertSame(b[0], ticket(s));
2079
+ assertEquals(
2080
+ "old cleanup must preserve successor notification",
2081
+ "completion-b",
2082
+ ForegroundService.mCurrentNotificationId);
2083
+ assertNotNull(field(s, "mRunnerToken"));
2084
+ assertTrue(b[0].valid);
2085
+ SystemClock.sleep(60001);
2086
+ receive(b[0].intent);
2087
+ assertFalse("authentic successor expiry still works", platform(s).foreground);
2088
+ } finally {
2089
+ EventBus.unregister(runners);
2090
+ }
2091
+ }
2092
+
2093
+ @Test
2094
+ public void PUB_typeCommitCannotOverwriteReentrantSuccessor() throws Exception {
2095
+ ForegroundService s = publicArmed("type-a");
2096
+ Object[] successor = new Object[2];
2097
+ Alarms.duringCancel =
2098
+ () -> {
2099
+ ForegroundService.stop();
2100
+ shadowOf(Looper.getMainLooper()).idle();
2101
+ publicStart(publicBundle("type-b", null));
2102
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 3);
2103
+ try {
2104
+ successor[0] = field(s, "mForegroundEpoch");
2105
+ successor[1] = field(s, "mRunnerToken");
2106
+ } catch (Exception failure) {
2107
+ throw new AssertionError(failure);
2108
+ }
2109
+ };
2110
+ Bundle update = publicBundle("type-a", null);
2111
+ update
2112
+ .getBundle("android")
2113
+ .putIntegerArrayList("foregroundServiceTypes", new ArrayList<>(Arrays.asList(DATA, 2)));
2114
+ publicStart(update);
2115
+ deliver(s, sender.starts.get(1), 2);
2116
+ assertSame("old type commit must preserve B epoch", successor[0], field(s, "mForegroundEpoch"));
2117
+ assertSame(successor[1], field(s, "mRunnerToken"));
2118
+ assertEquals("type-b", ForegroundService.mCurrentNotificationId);
2119
+ assertEquals("type-b", ((Bundle) field(null, "mCurrentNotificationBundle")).getString("id"));
2120
+ assertEquals(DATA, ForegroundService.mCurrentForegroundServiceType);
2121
+ assertTrue(platform(s).foreground);
2122
+ }
2123
+
2124
+ static Runnable publicBootHook;
2125
+
2126
+ public static class BootReadService extends ForegroundService {
2127
+ @Override
2128
+ public ContentResolver getContentResolver() {
2129
+ if (publicBootHook != null) {
2130
+ Runnable hook = publicBootHook;
2131
+ publicBootHook = null;
2132
+ hook.run();
2133
+ throw new IllegalStateException("old ticket boot provider failed");
2134
+ }
2135
+ return super.getContentResolver();
2136
+ }
2137
+ }
2138
+
2139
+ @Test
2140
+ public void PUB_oldConstructionFailureCannotCancelNewTicket() throws Exception {
2141
+ ServiceController<BootReadService> controller = Robolectric.buildService(BootReadService.class);
2142
+ controllers.add(controller);
2143
+ ForegroundService s = controller.create().get();
2144
+ ForegroundServiceRunLimitScheduler.Ticket[] b = {null};
2145
+ publicBootHook =
2146
+ () -> {
2147
+ ForegroundService.stop();
2148
+ shadowOf(Looper.getMainLooper()).idle();
2149
+ publicStart(publicBundle("boot-b", 60000L));
2150
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 2);
2151
+ try {
2152
+ b[0] = ticket(s);
2153
+ assertNotNull(b[0]);
2154
+ } catch (Exception failure) {
2155
+ throw new AssertionError(failure);
2156
+ }
2157
+ };
2158
+ try {
2159
+ publicStart(publicBundle("boot-a", 60000L));
2160
+ deliver(s, sender.starts.get(0), 1);
2161
+ assertNotNull(b[0]);
2162
+ assertSame("A's catch cannot cancel B", b[0], ticket(s));
2163
+ assertTrue(b[0].valid);
2164
+ SystemClock.sleep(60001);
2165
+ receive(b[0].intent);
2166
+ assertFalse(platform(s).foreground);
2167
+ } finally {
2168
+ publicBootHook = null;
2169
+ }
2170
+ }
2171
+
2172
+ @Test
2173
+ public void PUB_nativeFacadeReportsValidationThroughCallback() throws Exception {
2174
+ java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1);
2175
+ java.util.concurrent.atomic.AtomicReference<Exception> error =
2176
+ new java.util.concurrent.atomic.AtomicReference<>();
2177
+ try {
2178
+ Notifee.getInstance()
2179
+ .displayNotification(
2180
+ publicBundle("native-invalid", Double.NaN),
2181
+ (failure, result) -> {
2182
+ error.set(failure);
2183
+ done.countDown();
2184
+ });
2185
+ } catch (RuntimeException failure) {
2186
+ fail("native facade must report validation through its async callback: " + failure);
2187
+ }
2188
+ assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
2189
+ assertNotNull(error.get());
2190
+ assertTrue(sender.starts.isEmpty());
2191
+ }
2192
+
2193
+ @Test
2194
+ public void PUB_terminalCancellationCannotStopSuccessor() throws Exception {
2195
+ ForegroundService s = publicArmed("terminal-a");
2196
+ ForegroundServiceRunLimitScheduler.Ticket[] b = {null};
2197
+ int[] priorStops = {0};
2198
+ Alarms.duringCancel =
2199
+ () -> {
2200
+ ForegroundService.stop();
2201
+ shadowOf(Looper.getMainLooper()).idle();
2202
+ publicStart(publicBundle("terminal-b", 60000L));
2203
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 3);
2204
+ try {
2205
+ b[0] = ticket(s);
2206
+ assertNotNull(b[0]);
2207
+ priorStops[0] = platform(s).stopCalls;
2208
+ } catch (Exception failure) {
2209
+ throw new AssertionError(failure);
2210
+ }
2211
+ };
2212
+ deliver(s, null, 2);
2213
+ shadowOf(Looper.getMainLooper()).idle();
2214
+ assertSame("old terminal cannot revoke B", b[0], ticket(s));
2215
+ assertEquals(priorStops[0], platform(s).stopCalls);
2216
+ assertTrue(b[0].valid);
2217
+ assertEquals("terminal-b", ForegroundService.mCurrentNotificationId);
2218
+ }
2219
+
2220
+ @Test
2221
+ public void PUB_terminalCancellationPreservesNewPendingStart() throws Exception {
2222
+ ForegroundService s = publicArmed("pending-a");
2223
+ Alarms.duringCancel = () -> publicStart(publicBundle("pending-b", null));
2224
+ deliver(s, null, 2);
2225
+ deliver(s, sender.starts.get(sender.starts.size() - 1), 3);
2226
+ shadowOf(Looper.getMainLooper()).idle();
2227
+ assertFalse("newer accepted pending START supersedes old terminal", platform(s).stopped);
2228
+ assertEquals("pending-b", ForegroundService.mCurrentNotificationId);
2229
+ }
2230
+
2231
+ @Test
2232
+ public void PUB_frameworkTimeoutSurvivesFailedVoluntaryTeardown() throws Exception {
2233
+ ForegroundService s = publicArmed("fallback");
2234
+ Intent original = alarm();
2235
+ Platform.demotionFailure = true;
2236
+ Platform.stopFailure = true;
2237
+ SystemClock.sleep(60001);
2238
+ receive(original);
2239
+ assertTrue(platform(s).foreground);
2240
+ assertEquals(1, platform(s).demotions);
2241
+ Platform.demotionFailure = false;
2242
+ Platform.stopFailure = false;
2243
+ s.onTimeout(1, DATA);
2244
+ assertEquals(
2245
+ "framework timeout retains its independent teardown chance", 2, platform(s).demotions);
2246
+ assertFalse(platform(s).foreground);
2247
+ receive(original);
2248
+ assertEquals(2, platform(s).demotions);
2249
+ }
2250
+
2251
+ static class Sender extends ContextWrapper {
2252
+ final List<Intent> starts = new ArrayList<>();
2253
+ RuntimeException failure;
2254
+
2255
+ Sender(Context c) {
2256
+ super(c);
2257
+ }
2258
+
2259
+ @Override
2260
+ public ComponentName startForegroundService(Intent i) {
2261
+ starts.add(new Intent(i));
2262
+ if (failure != null) throw failure;
2263
+ return i.getComponent();
2264
+ }
2265
+ }
2266
+
2267
+ @Implements(AlarmManager.class)
2268
+ public static class Alarms extends ShadowAlarmManager {
2269
+ static int requested, type, cancelled;
2270
+ static long deadline;
2271
+ static boolean scheduleFailure, cancelFailure;
2272
+ static PendingIntent lastRequested;
2273
+ static Runnable afterSchedule, duringCancel;
2274
+ static List<PendingIntent> cancellations = new ArrayList<>();
2275
+ static List<PendingIntent> scheduled = new ArrayList<>();
2276
+
2277
+ public static void reset() {
2278
+ requested = cancelled = 0;
2279
+ scheduled.clear();
2280
+ scheduleFailure = cancelFailure = false;
2281
+ lastRequested = null;
2282
+ afterSchedule = duringCancel = null;
2283
+ cancellations.clear();
2284
+ }
2285
+
2286
+ @Implementation
2287
+ protected void setAndAllowWhileIdle(int t, long d, PendingIntent p) {
2288
+ requested++;
2289
+ type = t;
2290
+ deadline = d;
2291
+ lastRequested = p;
2292
+ if (scheduleFailure) throw new IllegalStateException("alarm unavailable");
2293
+ scheduled.add(p);
2294
+ if (afterSchedule != null) afterSchedule.run();
2295
+ }
2296
+
2297
+ @Implementation
2298
+ protected void cancel(PendingIntent p) {
2299
+ cancelled++;
2300
+ cancellations.add(p);
2301
+ Runnable hook = duringCancel;
2302
+ duringCancel = null;
2303
+ if (hook != null) hook.run();
2304
+ if (cancelFailure) throw new IllegalStateException("cancel lost reply");
2305
+ }
2306
+ }
2307
+
2308
+ @Implements(Service.class)
2309
+ public static class Platform extends ShadowService {
2310
+
2311
+ int accepted, delivered, demotions, stopCalls, lastStopId;
2312
+ boolean foreground, stopped, stopResult = true;
2313
+ final List<String> order = new ArrayList<>();
2314
+ static RuntimeException promotionFailure;
2315
+ static boolean demotionFailure, stopFailure;
2316
+ static Runnable beforePromotion, duringDemotion, duringStop;
2317
+
2318
+ static void reset() {
2319
+ promotionFailure = null;
2320
+ demotionFailure = stopFailure = false;
2321
+ beforePromotion = duringDemotion = duringStop = null;
2322
+ }
2323
+
2324
+ @Implementation
2325
+ protected void startForeground(int id, Notification n, int type) {
2326
+ if (beforePromotion != null) beforePromotion.run();
2327
+ if (promotionFailure != null) throw promotionFailure;
2328
+ foreground = true;
2329
+ }
2330
+
2331
+ @Implementation
2332
+ protected void stopForeground(int flags) {
2333
+ assertEquals(Service.STOP_FOREGROUND_REMOVE, flags);
2334
+ demotions++;
2335
+ order.add("demote");
2336
+ if (demotionFailure) throw new SecurityException("demotion denied");
2337
+ foreground = false;
2338
+ Runnable hook = duringDemotion;
2339
+ duringDemotion = null;
2340
+ if (hook != null) hook.run();
2341
+ }
2342
+
2343
+ @Implementation
2344
+ protected boolean stopSelfResult(int id) {
2345
+ stopCalls++;
2346
+ lastStopId = id;
2347
+ order.add("stop:" + id);
2348
+ if (stopFailure) throw new IllegalStateException("stop failure");
2349
+ boolean result = stopResult && id == accepted;
2350
+ stopped = result;
2351
+ Runnable hook = duringStop;
2352
+ duringStop = null;
2353
+ if (hook != null) hook.run();
2354
+ return result;
2355
+ }
2356
+ }
2357
+ }