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
@@ -33,6 +33,7 @@ import android.os.Bundle;
33
33
  import android.os.Handler;
34
34
  import android.os.IBinder;
35
35
  import android.os.Looper;
36
+ import android.os.SystemClock;
36
37
  import android.os.Trace;
37
38
  import androidx.annotation.Nullable;
38
39
  import androidx.annotation.RequiresApi;
@@ -68,6 +69,7 @@ public class ForegroundService extends Service {
68
69
  private static final Map<String, StartRequest> sPendingStarts = new HashMap<>();
69
70
  private static final String START_REQUEST_ID = "notifee.foregroundServiceStartRequestId";
70
71
  private static long sRunGeneration;
72
+ private static long sStartAdmissionRevision;
71
73
  private static ForegroundService sInstance;
72
74
  private static Object sStopRequest;
73
75
  private static ForegroundService sStateOwner;
@@ -83,6 +85,9 @@ public class ForegroundService extends Service {
83
85
  private int mRunFirstStartId;
84
86
  private Object mRunnerToken;
85
87
  private ForegroundEpoch mForegroundEpoch;
88
+ private ForegroundServiceRunLimitScheduler.Ticket mAlarmPoc;
89
+ private long mAlarmPocAttemptedRun = Long.MIN_VALUE;
90
+ private String mAlarmPocInstanceId;
86
91
  // Previously admitted metadata can collide with a successor even across run generations.
87
92
  // These IDs only disqualify stop authority; they never establish Android epoch provenance.
88
93
  private final Set<Integer> mTimeoutStartIds = new HashSet<>();
@@ -107,10 +112,14 @@ public class ForegroundService extends Service {
107
112
  // Guarded by sLifecycleLock then sLock. Retiring consumes timeout authority even on failure.
108
113
  private static final class ForegroundEpoch {
109
114
  final long runGeneration;
115
+ final long promotedElapsed = SystemClock.elapsedRealtime();
110
116
  final boolean ambiguousPromotion;
111
117
  final Set<Integer> satisfiedStartIds = new HashSet<>();
112
118
  int lastSatisfiedStartId;
113
119
  boolean retiring;
120
+ // A consumed voluntary alarm must not suppress a later genuine framework timeout
121
+ // when both native teardown attempts failed and the FGS is still active.
122
+ boolean voluntaryRetirement;
114
123
  boolean retired;
115
124
 
116
125
  ForegroundEpoch(long runGeneration, boolean ambiguousPromotion, int startId) {
@@ -122,13 +131,30 @@ public class ForegroundService extends Service {
122
131
  }
123
132
 
124
133
  // Caller holds both locks, after the actual promotion returned normally.
125
- private void commitForegroundEpoch() {
134
+ private boolean commitForegroundEpoch() {
126
135
  ForegroundEpoch previous = mForegroundEpoch;
136
+ long generation = sRunGeneration;
137
+ long runGeneration = mRunGeneration;
138
+ int startId = mLastStartId;
139
+ if (mAlarmPoc != null) {
140
+ ForegroundServiceRunLimitScheduler.marker(
141
+ "ARM_RETIRED", mAlarmPoc, "promotion epoch replaced; no automatic rearm");
142
+ }
143
+ invalidateAlarmPoc();
144
+ // Cancellation crosses IPC/storage. Neither this commit nor its caller may overwrite
145
+ // a successor admitted while the old ticket was being cancelled.
146
+ if (sInstance != this
147
+ || sStateOwner != this
148
+ || mForegroundEpoch != previous
149
+ || sRunGeneration != generation
150
+ || mRunGeneration != runGeneration
151
+ || mLastStartId != startId) return false;
127
152
  mForegroundEpoch =
128
153
  new ForegroundEpoch(
129
154
  mRunGeneration,
130
155
  previous != null && previous.runGeneration == mRunGeneration,
131
156
  mLastStartId);
157
+ return true;
132
158
  }
133
159
 
134
160
  // Snapshot before platform IPC; a reply cannot acquire authority over a reentrant successor.
@@ -203,6 +229,8 @@ public class ForegroundService extends Service {
203
229
  // Dispatch acceptance is distinct from the promotion outcome and the physical instance's
204
230
  // obligation. A denied delivered request is terminal even while its sender's reply is pending.
205
231
  boolean promotionDenied;
232
+ ForegroundServiceRunLimitScheduler.Request alarmPoc;
233
+ ForegroundEpoch alarmPocEpoch;
206
234
  // Null until delivery. An unfulfilled delivered request belongs to that physical instance.
207
235
  ForegroundService instance;
208
236
 
@@ -269,6 +297,7 @@ public class ForegroundService extends Service {
269
297
  synchronized (sLifecycleLock) {
270
298
  if (sInstance != null) {
271
299
  sInstance.mDeniedStartRecovery = null;
300
+ sInstance.invalidateAlarmPoc();
272
301
  }
273
302
  sInstance = this;
274
303
  mRunGeneration = sRunGeneration;
@@ -282,6 +311,7 @@ public class ForegroundService extends Service {
282
311
  @Override
283
312
  public void onDestroy() {
284
313
  synchronized (sLifecycleLock) {
314
+ invalidateAlarmPoc();
285
315
  mDeniedStartRecovery = null;
286
316
  sPendingStarts.values().removeIf(request -> request.instance == this);
287
317
  if (sInstance == this) {
@@ -311,8 +341,10 @@ public class ForegroundService extends Service {
311
341
  }
312
342
 
313
343
  private void clearOwnedState() {
344
+ ForegroundServiceRunLimitScheduler.Ticket ticket = null;
314
345
  synchronized (sLock) {
315
346
  if (sStateOwner == this) {
347
+ ticket = revokeAlarmPoc();
316
348
  mDeniedStartRecovery = null;
317
349
  clearCurrentState();
318
350
  mRunnerToken = null;
@@ -321,6 +353,8 @@ public class ForegroundService extends Service {
321
353
  }
322
354
  }
323
355
  }
356
+ // Finish A's local retirement before cancellation can synchronously admit a successor.
357
+ if (ticket != null) ForegroundServiceRunLimitScheduler.cancel(ticket);
324
358
  }
325
359
 
326
360
  /**
@@ -354,7 +388,46 @@ public class ForegroundService extends Service {
354
388
  }
355
389
  }
356
390
 
391
+ // Internal smoke entrypoint, deliberately absent from Notifee/React Native public APIs.
392
+ static void startAlarmPoc(
393
+ int hashCode,
394
+ Notification notification,
395
+ Bundle notificationBundle,
396
+ String run,
397
+ long requestedDurationMs) {
398
+ start(
399
+ hashCode,
400
+ notification,
401
+ notificationBundle,
402
+ new ForegroundServiceRunLimitScheduler.Request(
403
+ ContextHolder.getApplicationContext(), run, requestedDurationMs));
404
+ }
405
+
357
406
  static void start(int hashCode, Notification notification, Bundle notificationBundle) {
407
+ app.notifee.core.model.NotificationAndroidModel android =
408
+ NotificationModel.fromBundle(notificationBundle).getAndroid();
409
+ Long duration = android.getForegroundServiceBestEffortStopAfterMs();
410
+ ForegroundServiceRunLimitScheduler.Request request = null;
411
+ if (duration != null) {
412
+ // The model rejects this too; keep the platform guard explicit at API-specific calls.
413
+ if (Build.VERSION.SDK_INT < 35) {
414
+ throw new IllegalArgumentException(
415
+ "notification.android.foregroundServiceBestEffortStopAfterMs requires API 35+");
416
+ }
417
+ int type = android.getForegroundServiceType();
418
+ if (type == ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) type = resolveManifestServiceType();
419
+ request =
420
+ ForegroundServiceRunLimitScheduler.publicRequest(
421
+ ContextHolder.getApplicationContext(), duration, type);
422
+ }
423
+ start(hashCode, notification, notificationBundle, request);
424
+ }
425
+
426
+ private static void start(
427
+ int hashCode,
428
+ Notification notification,
429
+ Bundle notificationBundle,
430
+ ForegroundServiceRunLimitScheduler.Request alarmPoc) {
358
431
  Context context = ContextHolder.getApplicationContext();
359
432
  if (context == null) {
360
433
  Logger.e(TAG, "Application context is null; cannot start ForegroundService.");
@@ -395,8 +468,35 @@ public class ForegroundService extends Service {
395
468
  intent.putExtra(START_REQUEST_ID, requestId);
396
469
  StartRequest request;
397
470
  synchronized (sLifecycleLock) {
471
+ if (alarmPoc != null
472
+ && (!sPendingStarts.isEmpty()
473
+ || (sInstance != null
474
+ && (sInstance.mForegroundActive || sInstance.mRunnerToken != null)))) {
475
+ if (!alarmPoc.publicOption)
476
+ throw new IllegalStateException("Diagnostic arming requires a new quiescent run");
477
+ // Repeating the same option on an owned same-ID update is accepted without rearming.
478
+ // Enabling/changing it during an existing run or unresolved START is rejected
479
+ // pre-dispatch.
480
+ synchronized (sLock) {
481
+ ForegroundService owner = sInstance;
482
+ String id = NotificationModel.fromBundle(notificationBundle).getId();
483
+ if (!sPendingStarts.isEmpty()
484
+ || owner == null
485
+ || owner.mAlarmPoc == null
486
+ || !owner.ownsAlarmPoc(owner.mAlarmPoc)
487
+ || !id.equals(mCurrentNotificationId)
488
+ || alarmPoc.duration != owner.mAlarmPoc.duration) {
489
+ throw new IllegalArgumentException(
490
+ "notification.android.foregroundServiceBestEffortStopAfterMs cannot be enabled or"
491
+ + " changed during an active/pending run; stop and start a new run");
492
+ }
493
+ }
494
+ alarmPoc = null;
495
+ }
398
496
  request = new StartRequest(sRunGeneration, sStopRequest);
497
+ request.alarmPoc = alarmPoc;
399
498
  sPendingStarts.put(requestId, request);
499
+ sStartAdmissionRevision++;
400
500
  }
401
501
  ComponentName component;
402
502
  try {
@@ -455,8 +555,11 @@ public class ForegroundService extends Service {
455
555
  if (sInstance != null) {
456
556
  sInstance.mDeniedStartRecovery = null;
457
557
  }
558
+ // Order STOP before cancellation IPC/storage: a reentrant later START must observe
559
+ // and be able to supersede this request, rather than inherit an outer stale STOP.
458
560
  sStopRequest = new Object();
459
561
  sRunGeneration++;
562
+ if (sInstance != null) sInstance.invalidateAlarmPoc();
460
563
  scheduleStop();
461
564
  }
462
565
  }
@@ -599,7 +702,22 @@ public class ForegroundService extends Service {
599
702
  boolean terminal =
600
703
  intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction());
601
704
  if (terminal) {
602
- sRunGeneration++;
705
+ long generation = ++sRunGeneration;
706
+ long admission = sStartAdmissionRevision;
707
+ long runGeneration = mRunGeneration;
708
+ ForegroundEpoch epoch = mForegroundEpoch;
709
+ invalidateAlarmPoc();
710
+ // This terminal delivery predates every START accepted during cancellation, including
711
+ // an undelivered obligation whose startId/epoch cannot yet reveal the new admission.
712
+ if (sInstance != this
713
+ || sStateOwner != this
714
+ || sRunGeneration != generation
715
+ || sStartAdmissionRevision != admission
716
+ || mRunGeneration != runGeneration
717
+ || mForegroundEpoch != epoch
718
+ || mLastStartId != startId) {
719
+ return START_STICKY_COMPATIBILITY;
720
+ }
603
721
  }
604
722
  if (terminal && !sPendingStarts.isEmpty()) {
605
723
  // The earlier accepted START must promote with its real notification/type first.
@@ -629,6 +747,7 @@ public class ForegroundService extends Service {
629
747
  if (!superseded && mRunGeneration != generation) {
630
748
  // Reserve the new session without retiring the previous submitted state or runner.
631
749
  // A failed promotion must preserve them, while older deliveries remain fenced.
750
+ invalidateAlarmPoc();
632
751
  mRunGeneration = generation;
633
752
  }
634
753
  }
@@ -649,6 +768,7 @@ public class ForegroundService extends Service {
649
768
  recordSatisfiedStart(startId);
650
769
  }
651
770
  settleDeliveredStarts();
771
+ if (!terminal && !superseded) maybeArmAlarmPoc(request);
652
772
  }
653
773
  if (superseded
654
774
  && mStartCommandSatisfied
@@ -878,7 +998,9 @@ public class ForegroundService extends Service {
878
998
  }
879
999
 
880
1000
  if (!noneEarlyReturn && deniedInitialPromotion == null) {
881
- commitForegroundEpoch();
1001
+ if (!commitForegroundEpoch()) return START_NOT_STICKY;
1002
+ if (request != null && request.alarmPoc != null)
1003
+ request.alarmPocEpoch = mForegroundEpoch;
882
1004
  mCurrentNotificationId = notificationId;
883
1005
  mCurrentNotificationBundle = bundle;
884
1006
  mCurrentNotification = notification;
@@ -979,7 +1101,7 @@ public class ForegroundService extends Service {
979
1101
  mStartForegroundCalled = true;
980
1102
  mForegroundActive = true;
981
1103
  mStartCommandSatisfied = true;
982
- commitForegroundEpoch();
1104
+ if (!commitForegroundEpoch()) return START_NOT_STICKY;
983
1105
  }
984
1106
  // The platform call returned normally, so cache the latest submitted state for
985
1107
  // re-post and timeout. A normal return does not acknowledge display.
@@ -1309,6 +1431,7 @@ public class ForegroundService extends Service {
1309
1431
  private void handleTimeout(int startId, int fgsType, boolean shortServiceCallback) {
1310
1432
  Bundle notificationBundle;
1311
1433
  ForegroundEpoch epoch;
1434
+ ForegroundServiceRunLimitScheduler.Ticket timeoutAlarm;
1312
1435
  RuntimeException primary = null;
1313
1436
  String demotionOutcome = "failed";
1314
1437
  String stopOutcome = "deferred";
@@ -1323,7 +1446,7 @@ public class ForegroundService extends Service {
1323
1446
  if (sInstance != this
1324
1447
  || sStateOwner != this
1325
1448
  || epoch == null
1326
- || epoch.retiring
1449
+ || (epoch.retiring && !epoch.voluntaryRetirement)
1327
1450
  || epoch.retired
1328
1451
  || !mForegroundActive
1329
1452
  || (mRunFirstStartId != 0 && startId < mRunFirstStartId)
@@ -1343,6 +1466,8 @@ public class ForegroundService extends Service {
1343
1466
  mCurrentNotificationBundle == null ? null : new Bundle(mCurrentNotificationBundle);
1344
1467
  mTimeoutStartIds.add(startId);
1345
1468
  epoch.retiring = true;
1469
+ epoch.voluntaryRetirement = false;
1470
+ timeoutAlarm = revokeAlarmPoc();
1346
1471
  }
1347
1472
 
1348
1473
  // Nothing that posts an event, converts a model or retires owned evidence precedes this.
@@ -1380,6 +1505,8 @@ public class ForegroundService extends Service {
1380
1505
  }
1381
1506
  }
1382
1507
 
1508
+ if (timeoutAlarm != null) ForegroundServiceRunLimitScheduler.cancel(timeoutAlarm);
1509
+
1383
1510
  // Consume publication authority once, including when a reentrant caller still owns locks.
1384
1511
  final RuntimeException timeoutFailure = primary;
1385
1512
  final String diagnostic =
@@ -1429,4 +1556,267 @@ public class ForegroundService extends Service {
1429
1556
  publishTimeoutEventsIfUnlocked();
1430
1557
  if (primary != null) throw primary;
1431
1558
  }
1559
+
1560
+ // All diagnostic authority is subordinate to the existing lifecycle/epoch registry.
1561
+ // Caller holds sLifecycleLock; nested state checks always take sLock second.
1562
+ private void invalidateAlarmPoc() {
1563
+ ForegroundServiceRunLimitScheduler.Ticket ticket = revokeAlarmPoc();
1564
+ if (ticket != null) ForegroundServiceRunLimitScheduler.cancel(ticket);
1565
+ }
1566
+
1567
+ private ForegroundServiceRunLimitScheduler.Ticket revokeAlarmPoc() {
1568
+ ForegroundServiceRunLimitScheduler.Ticket ticket = mAlarmPoc;
1569
+ mAlarmPoc = null;
1570
+ if (ticket != null) ticket.valid = false;
1571
+ return ticket;
1572
+ }
1573
+
1574
+ private void maybeArmAlarmPoc(StartRequest request) {
1575
+ if (request == null || request.alarmPoc == null || Build.VERSION.SDK_INT < 35) return;
1576
+ synchronized (sLock) {
1577
+ ForegroundEpoch epoch = mForegroundEpoch;
1578
+ if ((!request.alarmPoc.publicOption && mAlarmPocAttemptedRun == mRunGeneration)
1579
+ || request.alarmPocEpoch != epoch) {
1580
+ ForegroundServiceRunLimitScheduler.marker(
1581
+ "ARM_REJECTED", null, "no new diagnostic promotion/run");
1582
+ return;
1583
+ }
1584
+ mAlarmPocAttemptedRun = mRunGeneration;
1585
+ if (sInstance != this
1586
+ || sStateOwner != this
1587
+ || epoch == null
1588
+ || epoch.retiring
1589
+ || epoch.retired
1590
+ || !mForegroundActive
1591
+ || !mStartCommandSatisfied
1592
+ || epoch.runGeneration != mRunGeneration
1593
+ || sRunGeneration != mRunGeneration
1594
+ || epoch.lastSatisfiedStartId != mLastStartId
1595
+ || mStopInitiated
1596
+ || mCurrentForegroundServiceType != ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) {
1597
+ ForegroundServiceRunLimitScheduler.marker(
1598
+ "ARM_REJECTED", null, "requires current single dataSync epoch");
1599
+ return;
1600
+ }
1601
+ ForegroundServiceRunLimitScheduler.Ticket ticket = null;
1602
+ try {
1603
+ // Promotion has already returned; no ledger/GC I/O is added to default START latency.
1604
+ if (request.alarmPoc.publicOption) ForegroundServiceRunLimitScheduler.collectOrphans(this);
1605
+ ticket =
1606
+ new ForegroundServiceRunLimitScheduler.Ticket(
1607
+ this,
1608
+ epoch,
1609
+ sRunGeneration,
1610
+ mRunGeneration,
1611
+ request.alarmPoc,
1612
+ epoch.promotedElapsed);
1613
+ // Ticket construction reads boot identity through a platform API. Revalidate after it.
1614
+ if (sInstance != this
1615
+ || sStateOwner != this
1616
+ || mForegroundEpoch != epoch
1617
+ || epoch.retiring
1618
+ || epoch.retired
1619
+ || sRunGeneration != ticket.generation
1620
+ || mRunGeneration != ticket.runGeneration) {
1621
+ ForegroundServiceRunLimitScheduler.cancel(ticket);
1622
+ return;
1623
+ }
1624
+ mAlarmPoc = ticket;
1625
+ if (!ForegroundServiceRunLimitScheduler.arm(ticket) || !ownsAlarmPoc(ticket)) {
1626
+ if (mAlarmPoc == ticket) mAlarmPoc = null;
1627
+ ForegroundServiceRunLimitScheduler.cancel(ticket);
1628
+ }
1629
+ } catch (org.json.JSONException | RuntimeException failure) {
1630
+ // Construction/storage may have admitted B reentrantly. Cleanup belongs only to A.
1631
+ if (ticket != null) {
1632
+ if (mAlarmPoc == ticket) mAlarmPoc = null;
1633
+ ForegroundServiceRunLimitScheduler.cancel(ticket);
1634
+ }
1635
+ ForegroundServiceRunLimitScheduler.marker("ARM_FAILED", ticket, failure.toString());
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ String alarmPocInstanceId() {
1641
+ if (mAlarmPocInstanceId == null) mAlarmPocInstanceId = UUID.randomUUID().toString();
1642
+ return mAlarmPocInstanceId;
1643
+ }
1644
+
1645
+ private boolean ownsAlarmPoc(ForegroundServiceRunLimitScheduler.Ticket ticket) {
1646
+ return Build.VERSION.SDK_INT >= 35
1647
+ && sInstance == this
1648
+ && sStateOwner == this
1649
+ && ticket.owner == this
1650
+ && mAlarmPoc == ticket
1651
+ && ticket.valid
1652
+ && ticket.epoch == mForegroundEpoch
1653
+ && sRunGeneration == ticket.generation
1654
+ && mRunGeneration == ticket.runGeneration
1655
+ && mForegroundEpoch != null
1656
+ && mForegroundEpoch.runGeneration == ticket.runGeneration
1657
+ && !mForegroundEpoch.retiring
1658
+ && !mForegroundEpoch.retired
1659
+ && mForegroundActive
1660
+ && mCurrentForegroundServiceType == ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
1661
+ && mStartCommandSatisfied
1662
+ && mLastStartId != 0
1663
+ && mLastStartId == mForegroundEpoch.lastSatisfiedStartId
1664
+ && !mStartCommandFailed
1665
+ && !mStopInitiated;
1666
+ }
1667
+
1668
+ static void receiveAlarmPoc(Intent intent) {
1669
+ try {
1670
+ synchronized (sLifecycleLock) {
1671
+ ForegroundService owner = sInstance;
1672
+ if (owner == null) {
1673
+ ForegroundServiceRunLimitScheduler.marker(
1674
+ "NO_MATCHING_LIVE_INSTANCE", null, "cold delivery");
1675
+ return;
1676
+ }
1677
+ owner.expireAlarmPoc(intent);
1678
+ }
1679
+ } finally {
1680
+ publishTimeoutEventsIfUnlocked();
1681
+ }
1682
+ }
1683
+
1684
+ private void expireAlarmPoc(Intent intent) {
1685
+ final ForegroundServiceRunLimitScheduler.Ticket ticket;
1686
+ final ForegroundEpoch epoch;
1687
+ final StopAuthority stop;
1688
+ final Bundle notificationBundle;
1689
+ final long deliveredElapsed;
1690
+ String demotionOutcome = "failed";
1691
+ String stopOutcome = "deferred";
1692
+ synchronized (sLock) {
1693
+ ticket = mAlarmPoc;
1694
+ if (ticket == null
1695
+ || !ownsAlarmPoc(ticket)
1696
+ || !ForegroundServiceRunLimitScheduler.matches(ticket, intent)) {
1697
+ ForegroundServiceRunLimitScheduler.marker(
1698
+ "ALARM_STALE", null, "no matching live authority");
1699
+ return;
1700
+ }
1701
+ ForegroundServiceRunLimitScheduler.marker("ALARM_DELIVERED", ticket, "native receiver");
1702
+ if (!ForegroundServiceRunLimitScheduler.ledgerMatches(ticket)) {
1703
+ if (mAlarmPoc == ticket) invalidateAlarmPoc();
1704
+ ForegroundServiceRunLimitScheduler.marker(
1705
+ "LEDGER_INVALID", ticket, "no teardown authority");
1706
+ return;
1707
+ }
1708
+ long now = SystemClock.elapsedRealtime();
1709
+ if (now < ticket.deadline) {
1710
+ ForegroundServiceRunLimitScheduler.marker("ALARM_EARLY", ticket, "no demotion");
1711
+ return;
1712
+ }
1713
+ if (now > ticket.deadline) {
1714
+ ForegroundServiceRunLimitScheduler.marker(
1715
+ "ALARM_LATE", ticket, "delayMs=" + (now - ticket.deadline));
1716
+ }
1717
+ // Boot/ledger validation crosses platform APIs. It cannot transfer the old alarm's
1718
+ // authority to a successor that appeared while those calls were in progress.
1719
+ if (!ownsAlarmPoc(ticket)) {
1720
+ ForegroundServiceRunLimitScheduler.marker(
1721
+ "ALARM_STALE", ticket, "owner changed during validation");
1722
+ return;
1723
+ }
1724
+ epoch = mForegroundEpoch;
1725
+ stop = captureStopAuthority();
1726
+ deliveredElapsed = now;
1727
+ notificationBundle =
1728
+ mCurrentNotificationBundle == null ? null : new Bundle(mCurrentNotificationBundle);
1729
+ mAlarmPoc = null;
1730
+ ticket.valid = false;
1731
+ epoch.retiring = true;
1732
+ epoch.voluntaryRetirement = true;
1733
+ }
1734
+ try {
1735
+ ForegroundServiceRunLimitScheduler.marker(
1736
+ "DEMOTION_ATTEMPTED",
1737
+ ticket,
1738
+ "REMOVE notificationId="
1739
+ + mCurrentNotificationId
1740
+ + " startId="
1741
+ + stop.startId
1742
+ + " satisfiedStartId="
1743
+ + epoch.lastSatisfiedStartId
1744
+ + " pendingStarts="
1745
+ + sPendingStarts.size());
1746
+ try {
1747
+ removeForegroundNotification();
1748
+ demotionOutcome = "returned";
1749
+ ForegroundServiceRunLimitScheduler.marker(
1750
+ "DEMOTION_RETURNED", ticket, "not an AMS acknowledgement");
1751
+ retireTimeoutEpoch(epoch);
1752
+ } catch (RuntimeException failure) {
1753
+ ForegroundServiceRunLimitScheduler.marker("DEMOTION_FAILED", ticket, failure.toString());
1754
+ }
1755
+ if (ownsTimeoutEpoch(epoch)
1756
+ && epoch.runGeneration == mRunGeneration
1757
+ && epoch.runGeneration == sRunGeneration
1758
+ && ownsStopAuthority(stop)
1759
+ && mLastStartId == epoch.lastSatisfiedStartId
1760
+ && mLastStartId != 0
1761
+ && !mStartCommandFailed
1762
+ && !mStopInitiated) {
1763
+ try {
1764
+ stopOutcome = "failed";
1765
+ boolean stopped = stopSelfResult(stop.startId);
1766
+ stopOutcome = stopped ? "accepted" : "refused";
1767
+ ForegroundServiceRunLimitScheduler.marker(
1768
+ stopped ? "STOP_ACCEPTED" : "STOP_REFUSED", ticket, "startId=" + stop.startId);
1769
+ if (stopped && ownsTimeoutEpoch(epoch) && ownsStopAuthority(stop)) {
1770
+ mStopInitiated = true;
1771
+ retireTimeoutEpoch(epoch);
1772
+ }
1773
+ } catch (RuntimeException failure) {
1774
+ ForegroundServiceRunLimitScheduler.marker("STOP_FAILED", ticket, failure.toString());
1775
+ }
1776
+ } else {
1777
+ ForegroundServiceRunLimitScheduler.marker(
1778
+ "STOP_DEFERRED", ticket, "pending/unknown START or ownership changed");
1779
+ }
1780
+ ForegroundServiceRunLimitScheduler.marker(
1781
+ "NATIVE_FINAL_STATE",
1782
+ ticket,
1783
+ "sameEpoch="
1784
+ + ownsTimeoutEpoch(epoch)
1785
+ + " foregroundActive="
1786
+ + mForegroundActive
1787
+ + " stopInitiated="
1788
+ + mStopInitiated
1789
+ + " pendingStarts="
1790
+ + sPendingStarts.size());
1791
+ } finally {
1792
+ ForegroundServiceRunLimitScheduler.cancel(ticket);
1793
+ }
1794
+ if (ticket.publicOption && notificationBundle != null) {
1795
+ Bundle metadata = new Bundle();
1796
+ metadata.putLong("requestedDurationMs", ticket.duration);
1797
+ metadata.putLong("deadlineElapsed", ticket.deadline);
1798
+ metadata.putLong("deliveredElapsed", deliveredElapsed);
1799
+ metadata.putString("demotion", demotionOutcome);
1800
+ metadata.putString("conditionalStop", stopOutcome);
1801
+ List<Runnable> publications = sTimeoutPublications.get();
1802
+ if (publications == null) {
1803
+ publications = new ArrayList<>();
1804
+ sTimeoutPublications.set(publications);
1805
+ }
1806
+ publications.add(
1807
+ () -> {
1808
+ try {
1809
+ Bundle extras = new Bundle();
1810
+ extras.putBundle("foregroundServiceRunLimit", metadata);
1811
+ EventBus.post(
1812
+ new NotificationEvent(
1813
+ NotificationEvent.TYPE_FG_RUN_LIMIT_REACHED,
1814
+ NotificationModel.fromBundle(notificationBundle),
1815
+ extras));
1816
+ } catch (RuntimeException failure) {
1817
+ ForegroundServiceRunLimitScheduler.marker("EVENT_FAILED", ticket, failure.toString());
1818
+ }
1819
+ });
1820
+ }
1821
+ }
1432
1822
  }
@@ -0,0 +1,15 @@
1
+ package app.notifee.core;
2
+
3
+ import android.content.BroadcastReceiver;
4
+ import android.content.Context;
5
+ import android.content.Intent;
6
+
7
+ /** Internal explicit receiver. Does not start a service or require React. */
8
+ public final class ForegroundServiceRunLimitReceiver extends BroadcastReceiver {
9
+ @Override
10
+ public void onReceive(Context context, Intent intent) {
11
+ if (intent == null || !ForegroundServiceRunLimitScheduler.ACTION.equals(intent.getAction()))
12
+ return;
13
+ ForegroundService.receiveAlarmPoc(intent);
14
+ }
15
+ }