react-native-notify-kit 10.8.0 → 10.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +34 -17
  2. package/android/build.gradle +50 -4
  3. package/android/src/main/AndroidManifest.xml +4 -0
  4. package/android/src/main/java/app/notifee/core/ForegroundService.java +1002 -58
  5. package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitReceiver.java +15 -0
  6. package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitScheduler.java +461 -0
  7. package/android/src/main/java/app/notifee/core/NotificationManager.java +6 -0
  8. package/android/src/main/java/app/notifee/core/event/NotificationEvent.java +6 -0
  9. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +29 -0
  10. package/android/src/main/kotlin/io/invertase/notifee/NotifeeEventSubscriber.kt +3 -0
  11. package/android/src/test/java/app/notifee/core/ForegroundServiceRunLimitTest.java +2357 -0
  12. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +3093 -21
  13. package/android/src/test/java/app/notifee/core/ShadowServiceCompatibilityTest.java +170 -0
  14. package/android/src/test/resources/robolectric.properties +4 -0
  15. package/cli/dist/cli.js +0 -1
  16. package/cli/dist/commands/initNse.js +1 -2
  17. package/cli/dist/lib/detectProject.js +1 -2
  18. package/cli/dist/lib/initNseCore.js +0 -1
  19. package/cli/dist/lib/logger.js +0 -1
  20. package/cli/dist/lib/patchPodfile.js +0 -1
  21. package/cli/dist/lib/patchXcodeProject.js +22 -3
  22. package/cli/dist/lib/writeTemplates.js +0 -1
  23. package/cli/dist/lib/xcode.js +6 -0
  24. package/cli/dist/templates/NotificationService.swift.tmpl +74 -6
  25. package/cli/package.json +1 -0
  26. package/cli/vendor/xcode/AUTHORS +6 -0
  27. package/cli/vendor/xcode/LICENSE +202 -0
  28. package/cli/vendor/xcode/NOTICE +5 -0
  29. package/cli/vendor/xcode/README.md +32 -0
  30. package/cli/vendor/xcode/UPSTREAM-SHA256.json +12 -0
  31. package/cli/vendor/xcode/index.js +18 -0
  32. package/cli/vendor/xcode/lib/parseJob.js +32 -0
  33. package/cli/vendor/xcode/lib/parser/pbxproj.js +1904 -0
  34. package/cli/vendor/xcode/lib/parser/pbxproj.pegjs +280 -0
  35. package/cli/vendor/xcode/lib/pbxFile.js +233 -0
  36. package/cli/vendor/xcode/lib/pbxProject.js +2205 -0
  37. package/cli/vendor/xcode/lib/pbxWriter.js +309 -0
  38. package/dist/NotifeeNativeModule.web.d.ts +2 -2
  39. package/dist/types/Notification.d.ts +36 -1
  40. package/dist/types/Notification.js +13 -0
  41. package/dist/types/Notification.js.map +1 -1
  42. package/dist/types/NotificationAndroid.d.ts +9 -0
  43. package/dist/types/NotificationAndroid.js.map +1 -1
  44. package/dist/validators/validateAndroidNotification.js +26 -1
  45. package/dist/validators/validateAndroidNotification.js.map +1 -1
  46. package/dist/validators/validateAndroidStyle.js +6 -0
  47. package/dist/validators/validateAndroidStyle.js.map +1 -1
  48. package/dist/validators/validateIOSAttachment.js +3 -1
  49. package/dist/validators/validateIOSAttachment.js.map +1 -1
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/package.json +8 -5
  53. package/src/NotifeeNativeModule.web.ts +2 -2
  54. package/src/types/Notification.ts +40 -0
  55. package/src/types/NotificationAndroid.ts +10 -0
  56. package/src/validators/validateAndroidNotification.ts +31 -1
  57. package/src/validators/validateAndroidStyle.ts +10 -2
  58. package/src/validators/validateIOSAttachment.ts +5 -1
  59. package/src/version.ts +1 -1
  60. package/cli/dist/cli.bundle.js +0 -20834
  61. package/cli/dist/cli.d.ts +0 -2
  62. package/cli/dist/cli.js.map +0 -1
  63. package/cli/dist/commands/initNse.d.ts +0 -12
  64. package/cli/dist/commands/initNse.js.map +0 -1
  65. package/cli/dist/lib/detectProject.d.ts +0 -12
  66. package/cli/dist/lib/detectProject.js.map +0 -1
  67. package/cli/dist/lib/initNseCore.d.ts +0 -8
  68. package/cli/dist/lib/initNseCore.js.map +0 -1
  69. package/cli/dist/lib/logger.d.ts +0 -4
  70. package/cli/dist/lib/logger.js.map +0 -1
  71. package/cli/dist/lib/patchPodfile.d.ts +0 -23
  72. package/cli/dist/lib/patchPodfile.js.map +0 -1
  73. package/cli/dist/lib/patchXcodeProject.d.ts +0 -41
  74. package/cli/dist/lib/patchXcodeProject.js.map +0 -1
  75. package/cli/dist/lib/writeTemplates.d.ts +0 -11
  76. package/cli/dist/lib/writeTemplates.js.map +0 -1
  77. package/cli/dist/templates/templates/Info.plist.tmpl +0 -29
  78. package/cli/dist/templates/templates/NotificationService.swift.tmpl +0 -100
  79. 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;
@@ -43,8 +44,12 @@ import app.notifee.core.event.NotificationEvent;
43
44
  import app.notifee.core.interfaces.MethodCallResult;
44
45
  import app.notifee.core.model.NotificationModel;
45
46
  import app.notifee.core.utility.ParcelableCompatReader;
47
+ import java.util.ArrayList;
46
48
  import java.util.HashMap;
49
+ import java.util.HashSet;
50
+ import java.util.List;
47
51
  import java.util.Map;
52
+ import java.util.Set;
48
53
  import java.util.UUID;
49
54
 
50
55
  public class ForegroundService extends Service {
@@ -64,6 +69,7 @@ public class ForegroundService extends Service {
64
69
  private static final Map<String, StartRequest> sPendingStarts = new HashMap<>();
65
70
  private static final String START_REQUEST_ID = "notifee.foregroundServiceStartRequestId";
66
71
  private static long sRunGeneration;
72
+ private static long sStartAdmissionRevision;
67
73
  private static ForegroundService sInstance;
68
74
  private static Object sStopRequest;
69
75
  private static ForegroundService sStateOwner;
@@ -78,6 +84,141 @@ public class ForegroundService extends Service {
78
84
  private long mStateGeneration;
79
85
  private int mRunFirstStartId;
80
86
  private Object mRunnerToken;
87
+ private ForegroundEpoch mForegroundEpoch;
88
+ private ForegroundServiceRunLimitScheduler.Ticket mAlarmPoc;
89
+ private long mAlarmPocAttemptedRun = Long.MIN_VALUE;
90
+ private String mAlarmPocInstanceId;
91
+ // Previously admitted metadata can collide with a successor even across run generations.
92
+ // These IDs only disqualify stop authority; they never establish Android epoch provenance.
93
+ private final Set<Integer> mTimeoutStartIds = new HashSet<>();
94
+ // A reentrant platform callback may still inherit an outer lifecycle monitor. Drain these
95
+ // publications synchronously at that call's unlocked boundary, without scheduling or waiting.
96
+ private static final ThreadLocal<List<Runnable>> sTimeoutPublications = new ThreadLocal<>();
97
+
98
+ private static void publishTimeoutEventsIfUnlocked() {
99
+ if (Thread.holdsLock(sLifecycleLock) || Thread.holdsLock(sLock)) {
100
+ return;
101
+ }
102
+ List<Runnable> publications = sTimeoutPublications.get();
103
+ if (publications != null) {
104
+ sTimeoutPublications.remove();
105
+ for (Runnable publication : publications) {
106
+ publication.run();
107
+ }
108
+ }
109
+ }
110
+
111
+ // Local publication identity, never provenance supplied by Android's timeout callback.
112
+ // Guarded by sLifecycleLock then sLock. Retiring consumes timeout authority even on failure.
113
+ private static final class ForegroundEpoch {
114
+ final long runGeneration;
115
+ final long promotedElapsed = SystemClock.elapsedRealtime();
116
+ final boolean ambiguousPromotion;
117
+ final Set<Integer> satisfiedStartIds = new HashSet<>();
118
+ int lastSatisfiedStartId;
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;
123
+ boolean retired;
124
+
125
+ ForegroundEpoch(long runGeneration, boolean ambiguousPromotion, int startId) {
126
+ this.runGeneration = runGeneration;
127
+ this.ambiguousPromotion = ambiguousPromotion;
128
+ satisfiedStartIds.add(startId);
129
+ lastSatisfiedStartId = startId;
130
+ }
131
+ }
132
+
133
+ // Caller holds both locks, after the actual promotion returned normally.
134
+ private boolean commitForegroundEpoch() {
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;
152
+ mForegroundEpoch =
153
+ new ForegroundEpoch(
154
+ mRunGeneration,
155
+ previous != null && previous.runGeneration == mRunGeneration,
156
+ mLastStartId);
157
+ return true;
158
+ }
159
+
160
+ // Snapshot before platform IPC; a reply cannot acquire authority over a reentrant successor.
161
+ private static final class StopAuthority {
162
+ final ForegroundService owner;
163
+ final ForegroundEpoch epoch;
164
+ final long generation;
165
+ final long runGeneration;
166
+ final int startId;
167
+ final Object request;
168
+
169
+ StopAuthority(ForegroundService service) {
170
+ owner = sStateOwner;
171
+ epoch = service.mForegroundEpoch;
172
+ generation = sRunGeneration;
173
+ runGeneration = service.mRunGeneration;
174
+ startId = service.mLastStartId;
175
+ request = sStopRequest;
176
+ }
177
+ }
178
+
179
+ // Caller holds sLifecycleLock. Follow the lifecycle -> state lock order for both snapshots.
180
+ private StopAuthority captureStopAuthority() {
181
+ synchronized (sLock) {
182
+ return new StopAuthority(this);
183
+ }
184
+ }
185
+
186
+ private boolean ownsStopAuthority(StopAuthority authority) {
187
+ synchronized (sLock) {
188
+ return sInstance == this
189
+ && sStateOwner == authority.owner
190
+ && mForegroundEpoch == authority.epoch
191
+ && sRunGeneration == authority.generation
192
+ && mRunGeneration == authority.runGeneration
193
+ && mLastStartId == authority.startId
194
+ && sStopRequest == authority.request
195
+ && sPendingStarts.isEmpty();
196
+ }
197
+ }
198
+
199
+ // A delivered command can be physically satisfied by the existing foreground publication,
200
+ // even if its notification update throws. A future/unfulfilled delivery gains no authority.
201
+ private void recordSatisfiedStart(int startId) {
202
+ synchronized (sLock) {
203
+ ForegroundEpoch epoch = mForegroundEpoch;
204
+ if (sInstance == this
205
+ && sStateOwner == this
206
+ && epoch != null
207
+ && !epoch.retiring
208
+ && !epoch.retired
209
+ && mForegroundActive
210
+ && mStartCommandSatisfied
211
+ && mLastStartId == startId
212
+ && epoch.runGeneration == mRunGeneration) {
213
+ epoch.satisfiedStartIds.add(startId);
214
+ epoch.lastSatisfiedStartId = startId;
215
+ }
216
+ }
217
+ }
218
+
219
+ // A denied initial promotion is not a client STOP. Its recovery cannot acquire authority
220
+ // over a later accepted/promoted run, and is consumed by one eligible stop attempt.
221
+ private DeniedStartRecovery mDeniedStartRecovery;
81
222
  private static final String DEFENSIVE_CHANNEL_ID = "notifee_fg_default";
82
223
  private static final int DEFENSIVE_NOTIFICATION_ID = Integer.MAX_VALUE - 1;
83
224
 
@@ -85,6 +226,11 @@ public class ForegroundService extends Service {
85
226
  final long generation;
86
227
  final Object precedingStop;
87
228
  boolean accepted;
229
+ // Dispatch acceptance is distinct from the promotion outcome and the physical instance's
230
+ // obligation. A denied delivered request is terminal even while its sender's reply is pending.
231
+ boolean promotionDenied;
232
+ ForegroundServiceRunLimitScheduler.Request alarmPoc;
233
+ ForegroundEpoch alarmPocEpoch;
88
234
  // Null until delivery. An unfulfilled delivered request belongs to that physical instance.
89
235
  ForegroundService instance;
90
236
 
@@ -94,6 +240,36 @@ public class ForegroundService extends Service {
94
240
  }
95
241
  }
96
242
 
243
+ private static final class DeniedStartRecovery {
244
+ final long generation;
245
+ final long runGeneration;
246
+ final int startId;
247
+ final int flags;
248
+ final RuntimeException denial;
249
+ final String requestId;
250
+ final String notificationId;
251
+ final int foregroundServiceType;
252
+
253
+ DeniedStartRecovery(
254
+ long generation,
255
+ long runGeneration,
256
+ int startId,
257
+ int flags,
258
+ RuntimeException denial,
259
+ String requestId,
260
+ String notificationId,
261
+ int foregroundServiceType) {
262
+ this.generation = generation;
263
+ this.runGeneration = runGeneration;
264
+ this.startId = startId;
265
+ this.flags = flags;
266
+ this.denial = denial;
267
+ this.requestId = requestId;
268
+ this.notificationId = notificationId;
269
+ this.foregroundServiceType = foregroundServiceType;
270
+ }
271
+ }
272
+
97
273
  /**
98
274
  * Tracks whether startForeground() has been called on THIS service instance. This is an instance
99
275
  * field (not static) because the static fields below track cross-invocation notification state
@@ -119,6 +295,10 @@ public class ForegroundService extends Service {
119
295
  public void onCreate() {
120
296
  super.onCreate();
121
297
  synchronized (sLifecycleLock) {
298
+ if (sInstance != null) {
299
+ sInstance.mDeniedStartRecovery = null;
300
+ sInstance.invalidateAlarmPoc();
301
+ }
122
302
  sInstance = this;
123
303
  mRunGeneration = sRunGeneration;
124
304
  synchronized (sLock) {
@@ -131,6 +311,8 @@ public class ForegroundService extends Service {
131
311
  @Override
132
312
  public void onDestroy() {
133
313
  synchronized (sLifecycleLock) {
314
+ invalidateAlarmPoc();
315
+ mDeniedStartRecovery = null;
134
316
  sPendingStarts.values().removeIf(request -> request.instance == this);
135
317
  if (sInstance == this) {
136
318
  sInstance = null;
@@ -159,12 +341,20 @@ public class ForegroundService extends Service {
159
341
  }
160
342
 
161
343
  private void clearOwnedState() {
344
+ ForegroundServiceRunLimitScheduler.Ticket ticket = null;
162
345
  synchronized (sLock) {
163
346
  if (sStateOwner == this) {
347
+ ticket = revokeAlarmPoc();
348
+ mDeniedStartRecovery = null;
164
349
  clearCurrentState();
165
350
  mRunnerToken = null;
351
+ if (mForegroundEpoch != null) {
352
+ mForegroundEpoch.retired = true;
353
+ }
166
354
  }
167
355
  }
356
+ // Finish A's local retirement before cancellation can synchronously admit a successor.
357
+ if (ticket != null) ForegroundServiceRunLimitScheduler.cancel(ticket);
168
358
  }
169
359
 
170
360
  /**
@@ -176,20 +366,68 @@ public class ForegroundService extends Service {
176
366
  */
177
367
  @SuppressLint("MissingPermission")
178
368
  static boolean repostIfActive(String notificationId) {
179
- synchronized (sLock) {
180
- if (mCurrentNotificationId == null
181
- || !mCurrentNotificationId.equals(notificationId)
182
- || mCurrentNotification == null) {
183
- return false;
369
+ try {
370
+ synchronized (sLifecycleLock) {
371
+ synchronized (sLock) {
372
+ if (mCurrentNotificationId == null
373
+ || (sStateOwner != null
374
+ && sStateOwner.mForegroundEpoch != null
375
+ && sStateOwner.mForegroundEpoch.retiring)
376
+ || !mCurrentNotificationId.equals(notificationId)
377
+ || mCurrentNotification == null) {
378
+ return false;
379
+ }
380
+ Logger.w(TAG, "Submitting dismissed foreground service notification for re-post");
381
+ NotificationManagerCompat.from(ContextHolder.getApplicationContext())
382
+ .notify(mCurrentHashCode, mCurrentNotification);
383
+ return true;
384
+ }
184
385
  }
185
- Logger.w(TAG, "Submitting dismissed foreground service notification for re-post");
186
- NotificationManagerCompat.from(ContextHolder.getApplicationContext())
187
- .notify(mCurrentHashCode, mCurrentNotification);
188
- return true;
386
+ } finally {
387
+ publishTimeoutEventsIfUnlocked();
189
388
  }
190
389
  }
191
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
+
192
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) {
193
431
  Context context = ContextHolder.getApplicationContext();
194
432
  if (context == null) {
195
433
  Logger.e(TAG, "Application context is null; cannot start ForegroundService.");
@@ -230,8 +468,35 @@ public class ForegroundService extends Service {
230
468
  intent.putExtra(START_REQUEST_ID, requestId);
231
469
  StartRequest request;
232
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
+ }
233
496
  request = new StartRequest(sRunGeneration, sStopRequest);
497
+ request.alarmPoc = alarmPoc;
234
498
  sPendingStarts.put(requestId, request);
499
+ sStartAdmissionRevision++;
235
500
  }
236
501
  ComponentName component;
237
502
  try {
@@ -268,8 +533,14 @@ public class ForegroundService extends Service {
268
533
  // Caller holds sLifecycleLock. Delivery itself can prove acceptance before Context returns.
269
534
  // A late reply never cancels a STOP ordered after this START was reserved/delivered.
270
535
  private static void acceptStart(StartRequest request) {
536
+ if (request.promotionDenied) {
537
+ return;
538
+ }
271
539
  if (!request.accepted) {
272
540
  request.accepted = true;
541
+ if (sInstance != null) {
542
+ sInstance.mDeniedStartRecovery = null;
543
+ }
273
544
  if (sStopRequest == request.precedingStop) {
274
545
  sStopRequest = null;
275
546
  }
@@ -281,8 +552,14 @@ public class ForegroundService extends Service {
281
552
  if (sInstance == null && sPendingStarts.isEmpty()) {
282
553
  return;
283
554
  }
555
+ if (sInstance != null) {
556
+ sInstance.mDeniedStartRecovery = null;
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.
284
560
  sStopRequest = new Object();
285
561
  sRunGeneration++;
562
+ if (sInstance != null) sInstance.invalidateAlarmPoc();
286
563
  scheduleStop();
287
564
  }
288
565
  }
@@ -295,9 +572,89 @@ public class ForegroundService extends Service {
295
572
  if (instance != null && request != null) {
296
573
  new Handler(Looper.getMainLooper()).post(() -> instance.stopIfRequested(request));
297
574
  }
575
+ if (instance != null && instance.mDeniedStartRecovery != null && sPendingStarts.isEmpty()) {
576
+ DeniedStartRecovery recovery = instance.mDeniedStartRecovery;
577
+ new Handler(Looper.getMainLooper()).post(() -> instance.stopDeniedStartIfCurrent(recovery));
578
+ }
579
+ }
580
+
581
+ private void stopDeniedStartIfCurrent(DeniedStartRecovery recovery) {
582
+ try {
583
+ stopDeniedStartIfCurrentLocked(recovery);
584
+ } finally {
585
+ publishTimeoutEventsIfUnlocked();
586
+ }
587
+ }
588
+
589
+ private void stopDeniedStartIfCurrentLocked(DeniedStartRecovery recovery) {
590
+ synchronized (sLifecycleLock) {
591
+ if (mDeniedStartRecovery != recovery) {
592
+ return;
593
+ }
594
+ synchronized (sLock) {
595
+ if (!ownsUnpromotedState()
596
+ || sRunGeneration != recovery.generation
597
+ || mRunGeneration != recovery.runGeneration
598
+ || mLastStartId != recovery.startId
599
+ || mStopInitiated) {
600
+ mDeniedStartRecovery = null;
601
+ return;
602
+ }
603
+ }
604
+ String stopOutcome = "deferred";
605
+ try {
606
+ if (sPendingStarts.isEmpty()) {
607
+ StopAuthority authority = captureStopAuthority();
608
+ // Consume before Binder IPC: refusal or failure grants no automatic retry authority.
609
+ mDeniedStartRecovery = null;
610
+ stopOutcome = "failed";
611
+ boolean stopped = stopSelfResult(recovery.startId);
612
+ stopOutcome = stopped ? "accepted" : "refused";
613
+ synchronized (sLock) {
614
+ if (stopped && ownsStopAuthority(authority) && ownsUnpromotedState()) {
615
+ mStopInitiated = true;
616
+ }
617
+ }
618
+ }
619
+ } finally {
620
+ // Preserve the promotion diagnostic even if the separate stop operation throws.
621
+ Logger.w(
622
+ TAG,
623
+ (recovery.requestId == null
624
+ ? "Framework redelivered START promotion denied (startId="
625
+ : "Tracked START PROMOTION_DENIED (cause=UNKNOWN, requestId="
626
+ + recovery.requestId
627
+ + ", notificationId="
628
+ + recovery.notificationId
629
+ + ", api="
630
+ + Build.VERSION.SDK_INT
631
+ + ", type="
632
+ + recovery.foregroundServiceType
633
+ + ", generation="
634
+ + recovery.generation
635
+ + ", runGeneration="
636
+ + recovery.runGeneration
637
+ + ", startId=")
638
+ + recovery.startId
639
+ + ", flags="
640
+ + recovery.flags
641
+ + ", stop="
642
+ + stopOutcome
643
+ + ").",
644
+ recovery.denial);
645
+ }
646
+ }
298
647
  }
299
648
 
300
649
  private void stopIfRequested(Object request) {
650
+ try {
651
+ stopIfRequestedLocked(request);
652
+ } finally {
653
+ publishTimeoutEventsIfUnlocked();
654
+ }
655
+ }
656
+
657
+ private void stopIfRequestedLocked(Object request) {
301
658
  synchronized (sLifecycleLock) {
302
659
  if (sInstance != this
303
660
  || sStopRequest != request
@@ -307,24 +664,60 @@ public class ForegroundService extends Service {
307
664
  || mStartCommandFailed) {
308
665
  return;
309
666
  }
310
- // The framework token and startId also protect against a start we have not yet received.
311
- if (stopSelfResult(mLastStartId)) {
312
- mStopInitiated = true;
313
- sStopRequest = null;
314
- mForegroundActive = false;
315
- clearOwnedState();
667
+ // Consume this request before IPC so a reentrant START cannot schedule it against its
668
+ // successor. Refusal/failure restores it only while the original admission still owns it.
669
+ sStopRequest = null;
670
+ StopAuthority authority = captureStopAuthority();
671
+ boolean stopped = false;
672
+ try {
673
+ // The framework token and startId also protect an accepted, not yet delivered START.
674
+ stopped = stopSelfResult(authority.startId);
675
+ if (stopped && ownsStopAuthority(authority)) {
676
+ mStopInitiated = true;
677
+ mForegroundActive = false;
678
+ clearOwnedState();
679
+ }
680
+ } finally {
681
+ if (!stopped && ownsStopAuthority(authority)) {
682
+ sStopRequest = request;
683
+ }
316
684
  }
317
685
  }
318
686
  }
319
687
 
320
688
  @Override
321
689
  public int onStartCommand(Intent intent, int flags, int startId) {
690
+ try {
691
+ return onStartCommandLocked(intent, flags, startId);
692
+ } finally {
693
+ publishTimeoutEventsIfUnlocked();
694
+ }
695
+ }
696
+
697
+ private int onStartCommandLocked(Intent intent, int flags, int startId) {
322
698
  synchronized (sLifecycleLock) {
699
+ // Any later delivery supersedes the recovery startId, including terminal commands.
700
+ mDeniedStartRecovery = null;
323
701
  mLastStartId = startId;
324
702
  boolean terminal =
325
703
  intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction());
326
704
  if (terminal) {
327
- 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
+ }
328
721
  }
329
722
  if (terminal && !sPendingStarts.isEmpty()) {
330
723
  // The earlier accepted START must promote with its real notification/type first.
@@ -336,6 +729,13 @@ public class ForegroundService extends Service {
336
729
  }
337
730
  String requestId = intent == null ? null : intent.getStringExtra(START_REQUEST_ID);
338
731
  StartRequest request = sPendingStarts.get(requestId);
732
+ boolean frameworkRedelivery =
733
+ intent != null
734
+ && START_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())
735
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
736
+ && (flags & (START_FLAG_RETRY | START_FLAG_REDELIVERY)) != 0
737
+ && isValidStartRequestId(requestId)
738
+ && request == null;
339
739
  boolean superseded = false;
340
740
  if (!terminal) {
341
741
  long generation = request == null ? sRunGeneration : request.generation;
@@ -347,6 +747,7 @@ public class ForegroundService extends Service {
347
747
  if (!superseded && mRunGeneration != generation) {
348
748
  // Reserve the new session without retiring the previous submitted state or runner.
349
749
  // A failed promotion must preserve them, while older deliveries remain fenced.
750
+ invalidateAlarmPoc();
350
751
  mRunGeneration = generation;
351
752
  }
352
753
  }
@@ -358,11 +759,16 @@ public class ForegroundService extends Service {
358
759
  // that run already ended, still perform the old request's real promotion before teardown.
359
760
  result = mForegroundActive ? START_NOT_STICKY : fulfillSupersededStart(intent);
360
761
  } else {
361
- result = handleStartCommand(intent, flags, startId);
762
+ result =
763
+ handleStartCommand(intent, flags, startId, frameworkRedelivery, requestId, request);
362
764
  }
363
765
  mStartCommandFailed = false;
364
766
  if (mStartCommandSatisfied) {
767
+ if (!terminal && !superseded) {
768
+ recordSatisfiedStart(startId);
769
+ }
365
770
  settleDeliveredStarts();
771
+ if (!terminal && !superseded) maybeArmAlarmPoc(request);
366
772
  }
367
773
  if (superseded
368
774
  && mStartCommandSatisfied
@@ -379,6 +785,9 @@ public class ForegroundService extends Service {
379
785
  } catch (RuntimeException e) {
380
786
  mStartCommandFailed = !mStartCommandSatisfied;
381
787
  if (mStartCommandSatisfied) {
788
+ if (!terminal && !superseded) {
789
+ recordSatisfiedStart(startId);
790
+ }
382
791
  settleDeliveredStarts();
383
792
  }
384
793
  throw e;
@@ -386,10 +795,63 @@ public class ForegroundService extends Service {
386
795
  }
387
796
  }
388
797
 
798
+ private static boolean isValidStartRequestId(@Nullable String requestId) {
799
+ if (requestId == null || requestId.length() != 36) {
800
+ return false;
801
+ }
802
+ try {
803
+ return UUID.fromString(requestId).toString().equalsIgnoreCase(requestId);
804
+ } catch (IllegalArgumentException e) {
805
+ return false;
806
+ }
807
+ }
808
+
809
+ // Caller holds sLifecycleLock and sLock. Recovery cannot retire an acquired run or another owner.
810
+ private boolean ownsUnpromotedState() {
811
+ return sInstance == this
812
+ && sStateOwner == this
813
+ && !mStartForegroundCalled
814
+ && !mForegroundActive
815
+ && !mStartCommandSatisfied
816
+ && mRunnerToken == null
817
+ && mRunFirstStartId == 0
818
+ && mCurrentNotificationId == null
819
+ && mCurrentForegroundServiceType == -1
820
+ && mCurrentNotificationBundle == null
821
+ && mCurrentNotification == null
822
+ && mCurrentHashCode == 0;
823
+ }
824
+
825
+ // Caller holds both lifecycle/state locks. Revalidate the original admission after the platform
826
+ // attempt: a synchronous authority change must not authorize a stale request's recovery.
827
+ private boolean canRecoverTrackedDenial(
828
+ Intent intent,
829
+ String requestId,
830
+ StartRequest request,
831
+ long generation,
832
+ long runGeneration,
833
+ int startId) {
834
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
835
+ && START_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())
836
+ && isValidStartRequestId(requestId)
837
+ && request != null
838
+ && sPendingStarts.get(requestId) == request
839
+ && request.instance == this
840
+ && request.accepted
841
+ && !request.promotionDenied
842
+ && request.generation == runGeneration
843
+ && sRunGeneration == generation
844
+ && mRunGeneration == runGeneration
845
+ && mLastStartId == startId
846
+ && !mStopInitiated
847
+ && ownsUnpromotedState();
848
+ }
849
+
389
850
  // A real promotion (or an already foreground instance) satisfies the physical instance's
390
851
  // delivered obligations, including a prior ambiguous delivery. Undelivered STARTs remain held.
391
852
  // This does not turn a posting/type exception into success: onStartCommand still rethrows it.
392
853
  private void settleDeliveredStarts() {
854
+ mDeniedStartRecovery = null;
393
855
  sPendingStarts.values().removeIf(request -> request.instance == this);
394
856
  }
395
857
 
@@ -427,13 +889,22 @@ public class ForegroundService extends Service {
427
889
  }
428
890
 
429
891
  @SuppressLint({"ForegroundServiceType", "MissingPermission"})
430
- private int handleStartCommand(Intent intent, int flags, int startId) {
892
+ private int handleStartCommand(
893
+ Intent intent,
894
+ int flags,
895
+ int startId,
896
+ boolean frameworkRedelivery,
897
+ String requestId,
898
+ StartRequest request) {
431
899
  Trace.beginSection("notifee:ForegroundService.onStartCommand");
432
900
  try {
433
901
  // Check if action is to stop the foreground service
434
902
  if (intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())) {
903
+ StopAuthority authority = captureStopAuthority();
435
904
  ensureStartForegroundContractSatisfied(true);
436
- if (stopSelfResult(startId)) {
905
+ if (ownsStopAuthority(authority)
906
+ && stopSelfResult(authority.startId)
907
+ && ownsStopAuthority(authority)) {
437
908
  mStopInitiated = true;
438
909
  mForegroundActive = false;
439
910
  clearOwnedState();
@@ -456,9 +927,14 @@ public class ForegroundService extends Service {
456
927
 
457
928
  Object pendingEvent = null;
458
929
  boolean noneEarlyReturn = false;
930
+ RuntimeException deniedInitialPromotion = null;
931
+ int deniedForegroundServiceType = -1;
932
+ boolean deniedTrackedStart = false;
459
933
 
460
934
  synchronized (sLock) {
461
- if (mCurrentNotificationId == null || mStateGeneration != mRunGeneration) {
935
+ if (mCurrentNotificationId == null
936
+ || mStateGeneration != mRunGeneration
937
+ || (mForegroundEpoch != null && mForegroundEpoch.retiring)) {
462
938
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
463
939
  int foregroundServiceType =
464
940
  notificationModel.getAndroid().getForegroundServiceType();
@@ -475,16 +951,39 @@ public class ForegroundService extends Service {
475
951
  // Binder IPC (PackageManager + startForeground) and must not hold sLock.
476
952
  noneEarlyReturn = true;
477
953
  } else {
954
+ boolean recoverInitialDenial = frameworkRedelivery && ownsUnpromotedState();
955
+ long generation = sRunGeneration;
956
+ long runGeneration = mRunGeneration;
957
+ boolean recoverTrackedDenial =
958
+ canRecoverTrackedDenial(
959
+ intent, requestId, request, generation, runGeneration, startId);
478
960
  Trace.beginSection("notifee:startForeground");
479
961
  try {
480
962
  startForeground(hashCode, notification, foregroundServiceType);
963
+ } catch (RuntimeException e) {
964
+ // Only the real initial promotion is exempt. Type resolution, NONE, posting,
965
+ // updates and teardown retain their existing failure boundaries.
966
+ boolean trackedDenial =
967
+ recoverTrackedDenial
968
+ && canRecoverTrackedDenial(
969
+ intent, requestId, request, generation, runGeneration, startId);
970
+ if (!(trackedDenial || (recoverInitialDenial && ownsUnpromotedState()))
971
+ || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
972
+ || e.getClass() != ForegroundServiceStartNotAllowedException.class) {
973
+ throw e;
974
+ }
975
+ deniedInitialPromotion = e;
976
+ deniedForegroundServiceType = foregroundServiceType;
977
+ deniedTrackedStart = trackedDenial;
481
978
  } finally {
482
979
  Trace.endSection();
483
980
  }
484
- mStartForegroundCalled = true;
485
- mForegroundActive = true;
486
- mStartCommandSatisfied = true;
487
- mCurrentForegroundServiceType = foregroundServiceType;
981
+ if (deniedInitialPromotion == null) {
982
+ mStartForegroundCalled = true;
983
+ mForegroundActive = true;
984
+ mStartCommandSatisfied = true;
985
+ mCurrentForegroundServiceType = foregroundServiceType;
986
+ }
488
987
  }
489
988
  } else {
490
989
  Trace.beginSection("notifee:startForeground");
@@ -498,7 +997,10 @@ public class ForegroundService extends Service {
498
997
  mStartCommandSatisfied = true;
499
998
  }
500
999
 
501
- if (!noneEarlyReturn) {
1000
+ if (!noneEarlyReturn && deniedInitialPromotion == null) {
1001
+ if (!commitForegroundEpoch()) return START_NOT_STICKY;
1002
+ if (request != null && request.alarmPoc != null)
1003
+ request.alarmPocEpoch = mForegroundEpoch;
502
1004
  mCurrentNotificationId = notificationId;
503
1005
  mCurrentNotificationBundle = bundle;
504
1006
  mCurrentNotification = notification;
@@ -513,13 +1015,25 @@ public class ForegroundService extends Service {
513
1015
  (e, aVoid) -> {
514
1016
  Runnable complete =
515
1017
  () -> {
516
- synchronized (sLifecycleLock) {
517
- if (sInstance == this
518
- && mRunnerToken == runnerToken
519
- && sRunGeneration == runnerGeneration) {
520
- stopForegroundCompat();
521
- clearOwnedState();
1018
+ try {
1019
+ synchronized (sLifecycleLock) {
1020
+ if (sInstance == this
1021
+ && mRunnerToken == runnerToken
1022
+ && sRunGeneration == runnerGeneration
1023
+ && mForegroundEpoch != null
1024
+ && !mForegroundEpoch.retiring
1025
+ && !mForegroundEpoch.retired) {
1026
+ ForegroundEpoch epoch = mForegroundEpoch;
1027
+ stopForegroundCompat();
1028
+ if (sInstance == this
1029
+ && mForegroundEpoch == epoch
1030
+ && mRunnerToken == runnerToken) {
1031
+ clearOwnedState();
1032
+ }
1033
+ }
522
1034
  }
1035
+ } finally {
1036
+ publishTimeoutEventsIfUnlocked();
523
1037
  }
524
1038
  };
525
1039
  if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -533,6 +1047,8 @@ public class ForegroundService extends Service {
533
1047
  }
534
1048
  } else {
535
1049
  if (mCurrentNotificationId.equals(notificationId)) {
1050
+ ForegroundEpoch updateEpoch = mForegroundEpoch;
1051
+ long updateGeneration = sRunGeneration;
536
1052
  // Before API 31, updating the service's registered notification requires
537
1053
  // startForeground(). API 31+ processes a same-ID notify() against the foreground
538
1054
  // service record. Keep that path for an unchanged type: repeating
@@ -566,13 +1082,27 @@ public class ForegroundService extends Service {
566
1082
  } finally {
567
1083
  Trace.endSection();
568
1084
  }
569
- mStartForegroundCalled = true;
570
- mForegroundActive = true;
571
- mStartCommandSatisfied = true;
572
1085
  } else {
573
1086
  NotificationManagerCompat.from(ContextHolder.getApplicationContext())
574
1087
  .notify(mCurrentHashCode, notification);
575
1088
  }
1089
+ // A timeout or successor may have retired/replaced the publication during IPC.
1090
+ // A normal return cannot reacquire its cache, runner or promotion authority.
1091
+ if (!ownsTimeoutEpoch(updateEpoch)
1092
+ || updateEpoch == null
1093
+ || updateEpoch.retiring
1094
+ || updateEpoch.retired
1095
+ || mLastStartId != startId
1096
+ || mRunGeneration != updateEpoch.runGeneration
1097
+ || sRunGeneration != updateGeneration) {
1098
+ return START_NOT_STICKY;
1099
+ }
1100
+ if (shouldStartForeground) {
1101
+ mStartForegroundCalled = true;
1102
+ mForegroundActive = true;
1103
+ mStartCommandSatisfied = true;
1104
+ if (!commitForegroundEpoch()) return START_NOT_STICKY;
1105
+ }
576
1106
  // The platform call returned normally, so cache the latest submitted state for
577
1107
  // re-post and timeout. A normal return does not acknowledge display.
578
1108
  mCurrentForegroundServiceType = foregroundServiceType;
@@ -586,6 +1116,28 @@ public class ForegroundService extends Service {
586
1116
  }
587
1117
  }
588
1118
 
1119
+ if (deniedInitialPromotion != null) {
1120
+ // Release sLock before stop Binder IPC. Terminalize only the exact denied request;
1121
+ // this is not a successful promotion and cannot fulfill any other delivered START.
1122
+ if (deniedTrackedStart) {
1123
+ request.promotionDenied = true;
1124
+ sPendingStarts.remove(requestId, request);
1125
+ }
1126
+ DeniedStartRecovery recovery =
1127
+ new DeniedStartRecovery(
1128
+ sRunGeneration,
1129
+ mRunGeneration,
1130
+ startId,
1131
+ flags,
1132
+ deniedInitialPromotion,
1133
+ deniedTrackedStart ? requestId : null,
1134
+ notificationId,
1135
+ deniedForegroundServiceType);
1136
+ mDeniedStartRecovery = recovery;
1137
+ stopDeniedStartIfCurrent(recovery);
1138
+ return START_NOT_STICKY;
1139
+ }
1140
+
589
1141
  // Handle NONE early return outside the lock — the helper performs Binder IPC
590
1142
  // (PackageManager query + startForeground) that must not hold sLock.
591
1143
  if (noneEarlyReturn) {
@@ -618,7 +1170,11 @@ public class ForegroundService extends Service {
618
1170
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
619
1171
  @Override
620
1172
  public void onTimeout(int startId) {
621
- handleTimeout(startId, -1);
1173
+ try {
1174
+ handleTimeout(startId, -1, true);
1175
+ } finally {
1176
+ publishTimeoutEventsIfUnlocked();
1177
+ }
622
1178
  }
623
1179
 
624
1180
  /**
@@ -628,7 +1184,11 @@ public class ForegroundService extends Service {
628
1184
  @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
629
1185
  @Override
630
1186
  public void onTimeout(int startId, int fgsType) {
631
- handleTimeout(startId, fgsType);
1187
+ try {
1188
+ handleTimeout(startId, fgsType, false);
1189
+ } finally {
1190
+ publishTimeoutEventsIfUnlocked();
1191
+ }
632
1192
  }
633
1193
 
634
1194
  @RequiresApi(Build.VERSION_CODES.Q)
@@ -679,12 +1239,20 @@ public class ForegroundService extends Service {
679
1239
 
680
1240
  @SuppressWarnings("deprecation")
681
1241
  private void stopForegroundCompat() {
1242
+ ForegroundEpoch epoch = mForegroundEpoch;
1243
+ removeForegroundNotification();
1244
+ if (sInstance == this && sStateOwner == this && mForegroundEpoch == epoch) {
1245
+ mForegroundActive = false;
1246
+ }
1247
+ }
1248
+
1249
+ @SuppressWarnings("deprecation")
1250
+ private void removeForegroundNotification() {
682
1251
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
683
1252
  stopForeground(STOP_FOREGROUND_REMOVE);
684
1253
  } else {
685
1254
  stopForeground(true);
686
1255
  }
687
- mForegroundActive = false;
688
1256
  }
689
1257
 
690
1258
  /**
@@ -843,36 +1411,412 @@ public class ForegroundService extends Service {
843
1411
  stopForegroundCompat();
844
1412
  }
845
1413
 
846
- private void handleTimeout(int startId, int fgsType) {
847
- Logger.e(
848
- TAG,
849
- "Foreground service timed out (startId="
1414
+ // Caller holds sLifecycleLock. Platform calls may synchronously reenter lifecycle callbacks.
1415
+ private boolean ownsTimeoutEpoch(ForegroundEpoch epoch) {
1416
+ synchronized (sLock) {
1417
+ return sInstance == this && sStateOwner == this && mForegroundEpoch == epoch;
1418
+ }
1419
+ }
1420
+
1421
+ private void retireTimeoutEpoch(ForegroundEpoch epoch) {
1422
+ synchronized (sLock) {
1423
+ if (ownsTimeoutEpoch(epoch)) {
1424
+ mForegroundActive = false;
1425
+ clearOwnedState();
1426
+ }
1427
+ }
1428
+ }
1429
+
1430
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1431
+ private void handleTimeout(int startId, int fgsType, boolean shortServiceCallback) {
1432
+ Bundle notificationBundle;
1433
+ ForegroundEpoch epoch;
1434
+ ForegroundServiceRunLimitScheduler.Ticket timeoutAlarm;
1435
+ RuntimeException primary = null;
1436
+ String demotionOutcome = "failed";
1437
+ String stopOutcome = "deferred";
1438
+ synchronized (sLifecycleLock) {
1439
+ final boolean stopAuthorized;
1440
+ final int stopId;
1441
+ final Object stopRequest = sStopRequest;
1442
+ synchronized (sLock) {
1443
+ epoch = mForegroundEpoch;
1444
+ int callbackType =
1445
+ shortServiceCallback ? ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE : fgsType;
1446
+ if (sInstance != this
1447
+ || sStateOwner != this
1448
+ || epoch == null
1449
+ || (epoch.retiring && !epoch.voluntaryRetirement)
1450
+ || epoch.retired
1451
+ || !mForegroundActive
1452
+ || (mRunFirstStartId != 0 && startId < mRunFirstStartId)
1453
+ || callbackType <= 0
1454
+ || mCurrentForegroundServiceType <= 0
1455
+ || (callbackType & mCurrentForegroundServiceType) == 0) {
1456
+ return;
1457
+ }
1458
+ // Matching IDs are library authority only. A repeated platform promotion within the
1459
+ // same run can expose coincident metadata, so it never upgrades ambiguous provenance.
1460
+ stopAuthorized =
1461
+ !epoch.ambiguousPromotion
1462
+ && !mTimeoutStartIds.contains(startId)
1463
+ && epoch.satisfiedStartIds.contains(startId);
1464
+ stopId = epoch.lastSatisfiedStartId;
1465
+ notificationBundle =
1466
+ mCurrentNotificationBundle == null ? null : new Bundle(mCurrentNotificationBundle);
1467
+ mTimeoutStartIds.add(startId);
1468
+ epoch.retiring = true;
1469
+ epoch.voluntaryRetirement = false;
1470
+ timeoutAlarm = revokeAlarmPoc();
1471
+ }
1472
+
1473
+ // Nothing that posts an event, converts a model or retires owned evidence precedes this.
1474
+ try {
1475
+ removeForegroundNotification();
1476
+ demotionOutcome = "returned"; // Normal Binder return is not an AMS acknowledgement.
1477
+ retireTimeoutEpoch(epoch);
1478
+ } catch (RuntimeException failure) {
1479
+ primary = failure;
1480
+ }
1481
+
1482
+ if (stopAuthorized
1483
+ && ownsTimeoutEpoch(epoch)
1484
+ && epoch.runGeneration == mRunGeneration
1485
+ && epoch.runGeneration == sRunGeneration
1486
+ && sStopRequest == stopRequest
1487
+ && sPendingStarts.isEmpty()
1488
+ && mLastStartId == stopId
1489
+ && stopId != 0
1490
+ && !mStartCommandFailed
1491
+ && !mStopInitiated) {
1492
+ try {
1493
+ stopOutcome = "failed";
1494
+ StopAuthority authority = captureStopAuthority();
1495
+ boolean stopped = stopSelfResult(stopId);
1496
+ stopOutcome = stopped ? "accepted" : "refused";
1497
+ if (stopped && ownsTimeoutEpoch(epoch) && ownsStopAuthority(authority)) {
1498
+ mStopInitiated = true;
1499
+ retireTimeoutEpoch(epoch);
1500
+ }
1501
+ } catch (RuntimeException failure) {
1502
+ if (primary == null) primary = failure;
1503
+ else if (primary != failure) primary.addSuppressed(failure);
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ if (timeoutAlarm != null) ForegroundServiceRunLimitScheduler.cancel(timeoutAlarm);
1509
+
1510
+ // Consume publication authority once, including when a reentrant caller still owns locks.
1511
+ final RuntimeException timeoutFailure = primary;
1512
+ final String diagnostic =
1513
+ "Foreground timeout (startId="
850
1514
  + startId
851
1515
  + ", type="
852
1516
  + fgsType
853
- + "). Stopping service.");
1517
+ + ", runGeneration="
1518
+ + epoch.runGeneration
1519
+ + ", demotion="
1520
+ + demotionOutcome
1521
+ + ", stop="
1522
+ + stopOutcome
1523
+ + ").";
1524
+ List<Runnable> publications = sTimeoutPublications.get();
1525
+ if (publications == null) {
1526
+ publications = new ArrayList<>();
1527
+ sTimeoutPublications.set(publications);
1528
+ }
1529
+ publications.add(
1530
+ () -> {
1531
+ if (notificationBundle != null) {
1532
+ try {
1533
+ NotificationModel model = NotificationModel.fromBundle(notificationBundle);
1534
+ Bundle extras = new Bundle();
1535
+ extras.putInt("startId", startId);
1536
+ extras.putInt("fgsType", fgsType);
1537
+ EventBus.post(
1538
+ new NotificationEvent(NotificationEvent.TYPE_FG_TIMEOUT, model, extras));
1539
+ } catch (RuntimeException failure) {
1540
+ if (timeoutFailure != null && timeoutFailure != failure)
1541
+ timeoutFailure.addSuppressed(failure);
1542
+ Logger.w(TAG, "Timeout event publication failed", failure);
1543
+ }
1544
+ }
1545
+ // Preserve the existing error LogEvent, but only after the essential teardown and
1546
+ // timeout publication. Its failure cannot replace a primary platform exception.
1547
+ try {
1548
+ if (timeoutFailure == null) Logger.e(TAG, diagnostic);
1549
+ else Logger.e(TAG, diagnostic, timeoutFailure);
1550
+ } catch (RuntimeException failure) {
1551
+ if (timeoutFailure != null && timeoutFailure != failure)
1552
+ timeoutFailure.addSuppressed(failure);
1553
+ Logger.w(TAG, "Timeout diagnostic publication failed", failure);
1554
+ }
1555
+ });
1556
+ publishTimeoutEventsIfUnlocked();
1557
+ if (primary != null) throw primary;
1558
+ }
854
1559
 
855
- Bundle notifBundle;
856
- synchronized (sLifecycleLock) {
857
- // shortService can report the first START of this run after same-ID updates. Reject only
858
- // callbacks from an earlier run, not earlier STARTs within the current run.
859
- if (sInstance != this || (mRunFirstStartId != 0 && startId < mRunFirstStartId)) {
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");
860
1582
  return;
861
1583
  }
862
- synchronized (sLock) {
863
- notifBundle = sStateOwner == this ? mCurrentNotificationBundle : null;
864
- clearOwnedState();
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());
865
1636
  }
866
- stopForegroundCompat();
867
- stopSelf(startId);
868
1637
  }
1638
+ }
869
1639
 
870
- if (notifBundle != null) {
871
- NotificationModel model = NotificationModel.fromBundle(notifBundle);
872
- Bundle extras = new Bundle();
873
- extras.putInt("startId", startId);
874
- extras.putInt("fgsType", fgsType);
875
- EventBus.post(new NotificationEvent(NotificationEvent.TYPE_FG_TIMEOUT, model, extras));
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
+ });
876
1820
  }
877
1821
  }
878
1822
  }