react-native-notify-kit 10.8.0 → 10.8.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.
@@ -43,8 +43,12 @@ import app.notifee.core.event.NotificationEvent;
43
43
  import app.notifee.core.interfaces.MethodCallResult;
44
44
  import app.notifee.core.model.NotificationModel;
45
45
  import app.notifee.core.utility.ParcelableCompatReader;
46
+ import java.util.ArrayList;
46
47
  import java.util.HashMap;
48
+ import java.util.HashSet;
49
+ import java.util.List;
47
50
  import java.util.Map;
51
+ import java.util.Set;
48
52
  import java.util.UUID;
49
53
 
50
54
  public class ForegroundService extends Service {
@@ -78,6 +82,117 @@ public class ForegroundService extends Service {
78
82
  private long mStateGeneration;
79
83
  private int mRunFirstStartId;
80
84
  private Object mRunnerToken;
85
+ private ForegroundEpoch mForegroundEpoch;
86
+ // Previously admitted metadata can collide with a successor even across run generations.
87
+ // These IDs only disqualify stop authority; they never establish Android epoch provenance.
88
+ private final Set<Integer> mTimeoutStartIds = new HashSet<>();
89
+ // A reentrant platform callback may still inherit an outer lifecycle monitor. Drain these
90
+ // publications synchronously at that call's unlocked boundary, without scheduling or waiting.
91
+ private static final ThreadLocal<List<Runnable>> sTimeoutPublications = new ThreadLocal<>();
92
+
93
+ private static void publishTimeoutEventsIfUnlocked() {
94
+ if (Thread.holdsLock(sLifecycleLock) || Thread.holdsLock(sLock)) {
95
+ return;
96
+ }
97
+ List<Runnable> publications = sTimeoutPublications.get();
98
+ if (publications != null) {
99
+ sTimeoutPublications.remove();
100
+ for (Runnable publication : publications) {
101
+ publication.run();
102
+ }
103
+ }
104
+ }
105
+
106
+ // Local publication identity, never provenance supplied by Android's timeout callback.
107
+ // Guarded by sLifecycleLock then sLock. Retiring consumes timeout authority even on failure.
108
+ private static final class ForegroundEpoch {
109
+ final long runGeneration;
110
+ final boolean ambiguousPromotion;
111
+ final Set<Integer> satisfiedStartIds = new HashSet<>();
112
+ int lastSatisfiedStartId;
113
+ boolean retiring;
114
+ boolean retired;
115
+
116
+ ForegroundEpoch(long runGeneration, boolean ambiguousPromotion, int startId) {
117
+ this.runGeneration = runGeneration;
118
+ this.ambiguousPromotion = ambiguousPromotion;
119
+ satisfiedStartIds.add(startId);
120
+ lastSatisfiedStartId = startId;
121
+ }
122
+ }
123
+
124
+ // Caller holds both locks, after the actual promotion returned normally.
125
+ private void commitForegroundEpoch() {
126
+ ForegroundEpoch previous = mForegroundEpoch;
127
+ mForegroundEpoch =
128
+ new ForegroundEpoch(
129
+ mRunGeneration,
130
+ previous != null && previous.runGeneration == mRunGeneration,
131
+ mLastStartId);
132
+ }
133
+
134
+ // Snapshot before platform IPC; a reply cannot acquire authority over a reentrant successor.
135
+ private static final class StopAuthority {
136
+ final ForegroundService owner;
137
+ final ForegroundEpoch epoch;
138
+ final long generation;
139
+ final long runGeneration;
140
+ final int startId;
141
+ final Object request;
142
+
143
+ StopAuthority(ForegroundService service) {
144
+ owner = sStateOwner;
145
+ epoch = service.mForegroundEpoch;
146
+ generation = sRunGeneration;
147
+ runGeneration = service.mRunGeneration;
148
+ startId = service.mLastStartId;
149
+ request = sStopRequest;
150
+ }
151
+ }
152
+
153
+ // Caller holds sLifecycleLock. Follow the lifecycle -> state lock order for both snapshots.
154
+ private StopAuthority captureStopAuthority() {
155
+ synchronized (sLock) {
156
+ return new StopAuthority(this);
157
+ }
158
+ }
159
+
160
+ private boolean ownsStopAuthority(StopAuthority authority) {
161
+ synchronized (sLock) {
162
+ return sInstance == this
163
+ && sStateOwner == authority.owner
164
+ && mForegroundEpoch == authority.epoch
165
+ && sRunGeneration == authority.generation
166
+ && mRunGeneration == authority.runGeneration
167
+ && mLastStartId == authority.startId
168
+ && sStopRequest == authority.request
169
+ && sPendingStarts.isEmpty();
170
+ }
171
+ }
172
+
173
+ // A delivered command can be physically satisfied by the existing foreground publication,
174
+ // even if its notification update throws. A future/unfulfilled delivery gains no authority.
175
+ private void recordSatisfiedStart(int startId) {
176
+ synchronized (sLock) {
177
+ ForegroundEpoch epoch = mForegroundEpoch;
178
+ if (sInstance == this
179
+ && sStateOwner == this
180
+ && epoch != null
181
+ && !epoch.retiring
182
+ && !epoch.retired
183
+ && mForegroundActive
184
+ && mStartCommandSatisfied
185
+ && mLastStartId == startId
186
+ && epoch.runGeneration == mRunGeneration) {
187
+ epoch.satisfiedStartIds.add(startId);
188
+ epoch.lastSatisfiedStartId = startId;
189
+ }
190
+ }
191
+ }
192
+
193
+ // A denied initial promotion is not a client STOP. Its recovery cannot acquire authority
194
+ // over a later accepted/promoted run, and is consumed by one eligible stop attempt.
195
+ private DeniedStartRecovery mDeniedStartRecovery;
81
196
  private static final String DEFENSIVE_CHANNEL_ID = "notifee_fg_default";
82
197
  private static final int DEFENSIVE_NOTIFICATION_ID = Integer.MAX_VALUE - 1;
83
198
 
@@ -85,6 +200,9 @@ public class ForegroundService extends Service {
85
200
  final long generation;
86
201
  final Object precedingStop;
87
202
  boolean accepted;
203
+ // Dispatch acceptance is distinct from the promotion outcome and the physical instance's
204
+ // obligation. A denied delivered request is terminal even while its sender's reply is pending.
205
+ boolean promotionDenied;
88
206
  // Null until delivery. An unfulfilled delivered request belongs to that physical instance.
89
207
  ForegroundService instance;
90
208
 
@@ -94,6 +212,36 @@ public class ForegroundService extends Service {
94
212
  }
95
213
  }
96
214
 
215
+ private static final class DeniedStartRecovery {
216
+ final long generation;
217
+ final long runGeneration;
218
+ final int startId;
219
+ final int flags;
220
+ final RuntimeException denial;
221
+ final String requestId;
222
+ final String notificationId;
223
+ final int foregroundServiceType;
224
+
225
+ DeniedStartRecovery(
226
+ long generation,
227
+ long runGeneration,
228
+ int startId,
229
+ int flags,
230
+ RuntimeException denial,
231
+ String requestId,
232
+ String notificationId,
233
+ int foregroundServiceType) {
234
+ this.generation = generation;
235
+ this.runGeneration = runGeneration;
236
+ this.startId = startId;
237
+ this.flags = flags;
238
+ this.denial = denial;
239
+ this.requestId = requestId;
240
+ this.notificationId = notificationId;
241
+ this.foregroundServiceType = foregroundServiceType;
242
+ }
243
+ }
244
+
97
245
  /**
98
246
  * Tracks whether startForeground() has been called on THIS service instance. This is an instance
99
247
  * field (not static) because the static fields below track cross-invocation notification state
@@ -119,6 +267,9 @@ public class ForegroundService extends Service {
119
267
  public void onCreate() {
120
268
  super.onCreate();
121
269
  synchronized (sLifecycleLock) {
270
+ if (sInstance != null) {
271
+ sInstance.mDeniedStartRecovery = null;
272
+ }
122
273
  sInstance = this;
123
274
  mRunGeneration = sRunGeneration;
124
275
  synchronized (sLock) {
@@ -131,6 +282,7 @@ public class ForegroundService extends Service {
131
282
  @Override
132
283
  public void onDestroy() {
133
284
  synchronized (sLifecycleLock) {
285
+ mDeniedStartRecovery = null;
134
286
  sPendingStarts.values().removeIf(request -> request.instance == this);
135
287
  if (sInstance == this) {
136
288
  sInstance = null;
@@ -161,8 +313,12 @@ public class ForegroundService extends Service {
161
313
  private void clearOwnedState() {
162
314
  synchronized (sLock) {
163
315
  if (sStateOwner == this) {
316
+ mDeniedStartRecovery = null;
164
317
  clearCurrentState();
165
318
  mRunnerToken = null;
319
+ if (mForegroundEpoch != null) {
320
+ mForegroundEpoch.retired = true;
321
+ }
166
322
  }
167
323
  }
168
324
  }
@@ -176,16 +332,25 @@ public class ForegroundService extends Service {
176
332
  */
177
333
  @SuppressLint("MissingPermission")
178
334
  static boolean repostIfActive(String notificationId) {
179
- synchronized (sLock) {
180
- if (mCurrentNotificationId == null
181
- || !mCurrentNotificationId.equals(notificationId)
182
- || mCurrentNotification == null) {
183
- return false;
335
+ try {
336
+ synchronized (sLifecycleLock) {
337
+ synchronized (sLock) {
338
+ if (mCurrentNotificationId == null
339
+ || (sStateOwner != null
340
+ && sStateOwner.mForegroundEpoch != null
341
+ && sStateOwner.mForegroundEpoch.retiring)
342
+ || !mCurrentNotificationId.equals(notificationId)
343
+ || mCurrentNotification == null) {
344
+ return false;
345
+ }
346
+ Logger.w(TAG, "Submitting dismissed foreground service notification for re-post");
347
+ NotificationManagerCompat.from(ContextHolder.getApplicationContext())
348
+ .notify(mCurrentHashCode, mCurrentNotification);
349
+ return true;
350
+ }
184
351
  }
185
- Logger.w(TAG, "Submitting dismissed foreground service notification for re-post");
186
- NotificationManagerCompat.from(ContextHolder.getApplicationContext())
187
- .notify(mCurrentHashCode, mCurrentNotification);
188
- return true;
352
+ } finally {
353
+ publishTimeoutEventsIfUnlocked();
189
354
  }
190
355
  }
191
356
 
@@ -268,8 +433,14 @@ public class ForegroundService extends Service {
268
433
  // Caller holds sLifecycleLock. Delivery itself can prove acceptance before Context returns.
269
434
  // A late reply never cancels a STOP ordered after this START was reserved/delivered.
270
435
  private static void acceptStart(StartRequest request) {
436
+ if (request.promotionDenied) {
437
+ return;
438
+ }
271
439
  if (!request.accepted) {
272
440
  request.accepted = true;
441
+ if (sInstance != null) {
442
+ sInstance.mDeniedStartRecovery = null;
443
+ }
273
444
  if (sStopRequest == request.precedingStop) {
274
445
  sStopRequest = null;
275
446
  }
@@ -281,6 +452,9 @@ public class ForegroundService extends Service {
281
452
  if (sInstance == null && sPendingStarts.isEmpty()) {
282
453
  return;
283
454
  }
455
+ if (sInstance != null) {
456
+ sInstance.mDeniedStartRecovery = null;
457
+ }
284
458
  sStopRequest = new Object();
285
459
  sRunGeneration++;
286
460
  scheduleStop();
@@ -295,9 +469,89 @@ public class ForegroundService extends Service {
295
469
  if (instance != null && request != null) {
296
470
  new Handler(Looper.getMainLooper()).post(() -> instance.stopIfRequested(request));
297
471
  }
472
+ if (instance != null && instance.mDeniedStartRecovery != null && sPendingStarts.isEmpty()) {
473
+ DeniedStartRecovery recovery = instance.mDeniedStartRecovery;
474
+ new Handler(Looper.getMainLooper()).post(() -> instance.stopDeniedStartIfCurrent(recovery));
475
+ }
476
+ }
477
+
478
+ private void stopDeniedStartIfCurrent(DeniedStartRecovery recovery) {
479
+ try {
480
+ stopDeniedStartIfCurrentLocked(recovery);
481
+ } finally {
482
+ publishTimeoutEventsIfUnlocked();
483
+ }
484
+ }
485
+
486
+ private void stopDeniedStartIfCurrentLocked(DeniedStartRecovery recovery) {
487
+ synchronized (sLifecycleLock) {
488
+ if (mDeniedStartRecovery != recovery) {
489
+ return;
490
+ }
491
+ synchronized (sLock) {
492
+ if (!ownsUnpromotedState()
493
+ || sRunGeneration != recovery.generation
494
+ || mRunGeneration != recovery.runGeneration
495
+ || mLastStartId != recovery.startId
496
+ || mStopInitiated) {
497
+ mDeniedStartRecovery = null;
498
+ return;
499
+ }
500
+ }
501
+ String stopOutcome = "deferred";
502
+ try {
503
+ if (sPendingStarts.isEmpty()) {
504
+ StopAuthority authority = captureStopAuthority();
505
+ // Consume before Binder IPC: refusal or failure grants no automatic retry authority.
506
+ mDeniedStartRecovery = null;
507
+ stopOutcome = "failed";
508
+ boolean stopped = stopSelfResult(recovery.startId);
509
+ stopOutcome = stopped ? "accepted" : "refused";
510
+ synchronized (sLock) {
511
+ if (stopped && ownsStopAuthority(authority) && ownsUnpromotedState()) {
512
+ mStopInitiated = true;
513
+ }
514
+ }
515
+ }
516
+ } finally {
517
+ // Preserve the promotion diagnostic even if the separate stop operation throws.
518
+ Logger.w(
519
+ TAG,
520
+ (recovery.requestId == null
521
+ ? "Framework redelivered START promotion denied (startId="
522
+ : "Tracked START PROMOTION_DENIED (cause=UNKNOWN, requestId="
523
+ + recovery.requestId
524
+ + ", notificationId="
525
+ + recovery.notificationId
526
+ + ", api="
527
+ + Build.VERSION.SDK_INT
528
+ + ", type="
529
+ + recovery.foregroundServiceType
530
+ + ", generation="
531
+ + recovery.generation
532
+ + ", runGeneration="
533
+ + recovery.runGeneration
534
+ + ", startId=")
535
+ + recovery.startId
536
+ + ", flags="
537
+ + recovery.flags
538
+ + ", stop="
539
+ + stopOutcome
540
+ + ").",
541
+ recovery.denial);
542
+ }
543
+ }
298
544
  }
299
545
 
300
546
  private void stopIfRequested(Object request) {
547
+ try {
548
+ stopIfRequestedLocked(request);
549
+ } finally {
550
+ publishTimeoutEventsIfUnlocked();
551
+ }
552
+ }
553
+
554
+ private void stopIfRequestedLocked(Object request) {
301
555
  synchronized (sLifecycleLock) {
302
556
  if (sInstance != this
303
557
  || sStopRequest != request
@@ -307,19 +561,40 @@ public class ForegroundService extends Service {
307
561
  || mStartCommandFailed) {
308
562
  return;
309
563
  }
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();
564
+ // Consume this request before IPC so a reentrant START cannot schedule it against its
565
+ // successor. Refusal/failure restores it only while the original admission still owns it.
566
+ sStopRequest = null;
567
+ StopAuthority authority = captureStopAuthority();
568
+ boolean stopped = false;
569
+ try {
570
+ // The framework token and startId also protect an accepted, not yet delivered START.
571
+ stopped = stopSelfResult(authority.startId);
572
+ if (stopped && ownsStopAuthority(authority)) {
573
+ mStopInitiated = true;
574
+ mForegroundActive = false;
575
+ clearOwnedState();
576
+ }
577
+ } finally {
578
+ if (!stopped && ownsStopAuthority(authority)) {
579
+ sStopRequest = request;
580
+ }
316
581
  }
317
582
  }
318
583
  }
319
584
 
320
585
  @Override
321
586
  public int onStartCommand(Intent intent, int flags, int startId) {
587
+ try {
588
+ return onStartCommandLocked(intent, flags, startId);
589
+ } finally {
590
+ publishTimeoutEventsIfUnlocked();
591
+ }
592
+ }
593
+
594
+ private int onStartCommandLocked(Intent intent, int flags, int startId) {
322
595
  synchronized (sLifecycleLock) {
596
+ // Any later delivery supersedes the recovery startId, including terminal commands.
597
+ mDeniedStartRecovery = null;
323
598
  mLastStartId = startId;
324
599
  boolean terminal =
325
600
  intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction());
@@ -336,6 +611,13 @@ public class ForegroundService extends Service {
336
611
  }
337
612
  String requestId = intent == null ? null : intent.getStringExtra(START_REQUEST_ID);
338
613
  StartRequest request = sPendingStarts.get(requestId);
614
+ boolean frameworkRedelivery =
615
+ intent != null
616
+ && START_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())
617
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
618
+ && (flags & (START_FLAG_RETRY | START_FLAG_REDELIVERY)) != 0
619
+ && isValidStartRequestId(requestId)
620
+ && request == null;
339
621
  boolean superseded = false;
340
622
  if (!terminal) {
341
623
  long generation = request == null ? sRunGeneration : request.generation;
@@ -358,10 +640,14 @@ public class ForegroundService extends Service {
358
640
  // that run already ended, still perform the old request's real promotion before teardown.
359
641
  result = mForegroundActive ? START_NOT_STICKY : fulfillSupersededStart(intent);
360
642
  } else {
361
- result = handleStartCommand(intent, flags, startId);
643
+ result =
644
+ handleStartCommand(intent, flags, startId, frameworkRedelivery, requestId, request);
362
645
  }
363
646
  mStartCommandFailed = false;
364
647
  if (mStartCommandSatisfied) {
648
+ if (!terminal && !superseded) {
649
+ recordSatisfiedStart(startId);
650
+ }
365
651
  settleDeliveredStarts();
366
652
  }
367
653
  if (superseded
@@ -379,6 +665,9 @@ public class ForegroundService extends Service {
379
665
  } catch (RuntimeException e) {
380
666
  mStartCommandFailed = !mStartCommandSatisfied;
381
667
  if (mStartCommandSatisfied) {
668
+ if (!terminal && !superseded) {
669
+ recordSatisfiedStart(startId);
670
+ }
382
671
  settleDeliveredStarts();
383
672
  }
384
673
  throw e;
@@ -386,10 +675,63 @@ public class ForegroundService extends Service {
386
675
  }
387
676
  }
388
677
 
678
+ private static boolean isValidStartRequestId(@Nullable String requestId) {
679
+ if (requestId == null || requestId.length() != 36) {
680
+ return false;
681
+ }
682
+ try {
683
+ return UUID.fromString(requestId).toString().equalsIgnoreCase(requestId);
684
+ } catch (IllegalArgumentException e) {
685
+ return false;
686
+ }
687
+ }
688
+
689
+ // Caller holds sLifecycleLock and sLock. Recovery cannot retire an acquired run or another owner.
690
+ private boolean ownsUnpromotedState() {
691
+ return sInstance == this
692
+ && sStateOwner == this
693
+ && !mStartForegroundCalled
694
+ && !mForegroundActive
695
+ && !mStartCommandSatisfied
696
+ && mRunnerToken == null
697
+ && mRunFirstStartId == 0
698
+ && mCurrentNotificationId == null
699
+ && mCurrentForegroundServiceType == -1
700
+ && mCurrentNotificationBundle == null
701
+ && mCurrentNotification == null
702
+ && mCurrentHashCode == 0;
703
+ }
704
+
705
+ // Caller holds both lifecycle/state locks. Revalidate the original admission after the platform
706
+ // attempt: a synchronous authority change must not authorize a stale request's recovery.
707
+ private boolean canRecoverTrackedDenial(
708
+ Intent intent,
709
+ String requestId,
710
+ StartRequest request,
711
+ long generation,
712
+ long runGeneration,
713
+ int startId) {
714
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
715
+ && START_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())
716
+ && isValidStartRequestId(requestId)
717
+ && request != null
718
+ && sPendingStarts.get(requestId) == request
719
+ && request.instance == this
720
+ && request.accepted
721
+ && !request.promotionDenied
722
+ && request.generation == runGeneration
723
+ && sRunGeneration == generation
724
+ && mRunGeneration == runGeneration
725
+ && mLastStartId == startId
726
+ && !mStopInitiated
727
+ && ownsUnpromotedState();
728
+ }
729
+
389
730
  // A real promotion (or an already foreground instance) satisfies the physical instance's
390
731
  // delivered obligations, including a prior ambiguous delivery. Undelivered STARTs remain held.
391
732
  // This does not turn a posting/type exception into success: onStartCommand still rethrows it.
392
733
  private void settleDeliveredStarts() {
734
+ mDeniedStartRecovery = null;
393
735
  sPendingStarts.values().removeIf(request -> request.instance == this);
394
736
  }
395
737
 
@@ -427,13 +769,22 @@ public class ForegroundService extends Service {
427
769
  }
428
770
 
429
771
  @SuppressLint({"ForegroundServiceType", "MissingPermission"})
430
- private int handleStartCommand(Intent intent, int flags, int startId) {
772
+ private int handleStartCommand(
773
+ Intent intent,
774
+ int flags,
775
+ int startId,
776
+ boolean frameworkRedelivery,
777
+ String requestId,
778
+ StartRequest request) {
431
779
  Trace.beginSection("notifee:ForegroundService.onStartCommand");
432
780
  try {
433
781
  // Check if action is to stop the foreground service
434
782
  if (intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())) {
783
+ StopAuthority authority = captureStopAuthority();
435
784
  ensureStartForegroundContractSatisfied(true);
436
- if (stopSelfResult(startId)) {
785
+ if (ownsStopAuthority(authority)
786
+ && stopSelfResult(authority.startId)
787
+ && ownsStopAuthority(authority)) {
437
788
  mStopInitiated = true;
438
789
  mForegroundActive = false;
439
790
  clearOwnedState();
@@ -456,9 +807,14 @@ public class ForegroundService extends Service {
456
807
 
457
808
  Object pendingEvent = null;
458
809
  boolean noneEarlyReturn = false;
810
+ RuntimeException deniedInitialPromotion = null;
811
+ int deniedForegroundServiceType = -1;
812
+ boolean deniedTrackedStart = false;
459
813
 
460
814
  synchronized (sLock) {
461
- if (mCurrentNotificationId == null || mStateGeneration != mRunGeneration) {
815
+ if (mCurrentNotificationId == null
816
+ || mStateGeneration != mRunGeneration
817
+ || (mForegroundEpoch != null && mForegroundEpoch.retiring)) {
462
818
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
463
819
  int foregroundServiceType =
464
820
  notificationModel.getAndroid().getForegroundServiceType();
@@ -475,16 +831,39 @@ public class ForegroundService extends Service {
475
831
  // Binder IPC (PackageManager + startForeground) and must not hold sLock.
476
832
  noneEarlyReturn = true;
477
833
  } else {
834
+ boolean recoverInitialDenial = frameworkRedelivery && ownsUnpromotedState();
835
+ long generation = sRunGeneration;
836
+ long runGeneration = mRunGeneration;
837
+ boolean recoverTrackedDenial =
838
+ canRecoverTrackedDenial(
839
+ intent, requestId, request, generation, runGeneration, startId);
478
840
  Trace.beginSection("notifee:startForeground");
479
841
  try {
480
842
  startForeground(hashCode, notification, foregroundServiceType);
843
+ } catch (RuntimeException e) {
844
+ // Only the real initial promotion is exempt. Type resolution, NONE, posting,
845
+ // updates and teardown retain their existing failure boundaries.
846
+ boolean trackedDenial =
847
+ recoverTrackedDenial
848
+ && canRecoverTrackedDenial(
849
+ intent, requestId, request, generation, runGeneration, startId);
850
+ if (!(trackedDenial || (recoverInitialDenial && ownsUnpromotedState()))
851
+ || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
852
+ || e.getClass() != ForegroundServiceStartNotAllowedException.class) {
853
+ throw e;
854
+ }
855
+ deniedInitialPromotion = e;
856
+ deniedForegroundServiceType = foregroundServiceType;
857
+ deniedTrackedStart = trackedDenial;
481
858
  } finally {
482
859
  Trace.endSection();
483
860
  }
484
- mStartForegroundCalled = true;
485
- mForegroundActive = true;
486
- mStartCommandSatisfied = true;
487
- mCurrentForegroundServiceType = foregroundServiceType;
861
+ if (deniedInitialPromotion == null) {
862
+ mStartForegroundCalled = true;
863
+ mForegroundActive = true;
864
+ mStartCommandSatisfied = true;
865
+ mCurrentForegroundServiceType = foregroundServiceType;
866
+ }
488
867
  }
489
868
  } else {
490
869
  Trace.beginSection("notifee:startForeground");
@@ -498,7 +877,8 @@ public class ForegroundService extends Service {
498
877
  mStartCommandSatisfied = true;
499
878
  }
500
879
 
501
- if (!noneEarlyReturn) {
880
+ if (!noneEarlyReturn && deniedInitialPromotion == null) {
881
+ commitForegroundEpoch();
502
882
  mCurrentNotificationId = notificationId;
503
883
  mCurrentNotificationBundle = bundle;
504
884
  mCurrentNotification = notification;
@@ -513,13 +893,25 @@ public class ForegroundService extends Service {
513
893
  (e, aVoid) -> {
514
894
  Runnable complete =
515
895
  () -> {
516
- synchronized (sLifecycleLock) {
517
- if (sInstance == this
518
- && mRunnerToken == runnerToken
519
- && sRunGeneration == runnerGeneration) {
520
- stopForegroundCompat();
521
- clearOwnedState();
896
+ try {
897
+ synchronized (sLifecycleLock) {
898
+ if (sInstance == this
899
+ && mRunnerToken == runnerToken
900
+ && sRunGeneration == runnerGeneration
901
+ && mForegroundEpoch != null
902
+ && !mForegroundEpoch.retiring
903
+ && !mForegroundEpoch.retired) {
904
+ ForegroundEpoch epoch = mForegroundEpoch;
905
+ stopForegroundCompat();
906
+ if (sInstance == this
907
+ && mForegroundEpoch == epoch
908
+ && mRunnerToken == runnerToken) {
909
+ clearOwnedState();
910
+ }
911
+ }
522
912
  }
913
+ } finally {
914
+ publishTimeoutEventsIfUnlocked();
523
915
  }
524
916
  };
525
917
  if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -533,6 +925,8 @@ public class ForegroundService extends Service {
533
925
  }
534
926
  } else {
535
927
  if (mCurrentNotificationId.equals(notificationId)) {
928
+ ForegroundEpoch updateEpoch = mForegroundEpoch;
929
+ long updateGeneration = sRunGeneration;
536
930
  // Before API 31, updating the service's registered notification requires
537
931
  // startForeground(). API 31+ processes a same-ID notify() against the foreground
538
932
  // service record. Keep that path for an unchanged type: repeating
@@ -566,13 +960,27 @@ public class ForegroundService extends Service {
566
960
  } finally {
567
961
  Trace.endSection();
568
962
  }
569
- mStartForegroundCalled = true;
570
- mForegroundActive = true;
571
- mStartCommandSatisfied = true;
572
963
  } else {
573
964
  NotificationManagerCompat.from(ContextHolder.getApplicationContext())
574
965
  .notify(mCurrentHashCode, notification);
575
966
  }
967
+ // A timeout or successor may have retired/replaced the publication during IPC.
968
+ // A normal return cannot reacquire its cache, runner or promotion authority.
969
+ if (!ownsTimeoutEpoch(updateEpoch)
970
+ || updateEpoch == null
971
+ || updateEpoch.retiring
972
+ || updateEpoch.retired
973
+ || mLastStartId != startId
974
+ || mRunGeneration != updateEpoch.runGeneration
975
+ || sRunGeneration != updateGeneration) {
976
+ return START_NOT_STICKY;
977
+ }
978
+ if (shouldStartForeground) {
979
+ mStartForegroundCalled = true;
980
+ mForegroundActive = true;
981
+ mStartCommandSatisfied = true;
982
+ commitForegroundEpoch();
983
+ }
576
984
  // The platform call returned normally, so cache the latest submitted state for
577
985
  // re-post and timeout. A normal return does not acknowledge display.
578
986
  mCurrentForegroundServiceType = foregroundServiceType;
@@ -586,6 +994,28 @@ public class ForegroundService extends Service {
586
994
  }
587
995
  }
588
996
 
997
+ if (deniedInitialPromotion != null) {
998
+ // Release sLock before stop Binder IPC. Terminalize only the exact denied request;
999
+ // this is not a successful promotion and cannot fulfill any other delivered START.
1000
+ if (deniedTrackedStart) {
1001
+ request.promotionDenied = true;
1002
+ sPendingStarts.remove(requestId, request);
1003
+ }
1004
+ DeniedStartRecovery recovery =
1005
+ new DeniedStartRecovery(
1006
+ sRunGeneration,
1007
+ mRunGeneration,
1008
+ startId,
1009
+ flags,
1010
+ deniedInitialPromotion,
1011
+ deniedTrackedStart ? requestId : null,
1012
+ notificationId,
1013
+ deniedForegroundServiceType);
1014
+ mDeniedStartRecovery = recovery;
1015
+ stopDeniedStartIfCurrent(recovery);
1016
+ return START_NOT_STICKY;
1017
+ }
1018
+
589
1019
  // Handle NONE early return outside the lock — the helper performs Binder IPC
590
1020
  // (PackageManager query + startForeground) that must not hold sLock.
591
1021
  if (noneEarlyReturn) {
@@ -618,7 +1048,11 @@ public class ForegroundService extends Service {
618
1048
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
619
1049
  @Override
620
1050
  public void onTimeout(int startId) {
621
- handleTimeout(startId, -1);
1051
+ try {
1052
+ handleTimeout(startId, -1, true);
1053
+ } finally {
1054
+ publishTimeoutEventsIfUnlocked();
1055
+ }
622
1056
  }
623
1057
 
624
1058
  /**
@@ -628,7 +1062,11 @@ public class ForegroundService extends Service {
628
1062
  @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
629
1063
  @Override
630
1064
  public void onTimeout(int startId, int fgsType) {
631
- handleTimeout(startId, fgsType);
1065
+ try {
1066
+ handleTimeout(startId, fgsType, false);
1067
+ } finally {
1068
+ publishTimeoutEventsIfUnlocked();
1069
+ }
632
1070
  }
633
1071
 
634
1072
  @RequiresApi(Build.VERSION_CODES.Q)
@@ -679,12 +1117,20 @@ public class ForegroundService extends Service {
679
1117
 
680
1118
  @SuppressWarnings("deprecation")
681
1119
  private void stopForegroundCompat() {
1120
+ ForegroundEpoch epoch = mForegroundEpoch;
1121
+ removeForegroundNotification();
1122
+ if (sInstance == this && sStateOwner == this && mForegroundEpoch == epoch) {
1123
+ mForegroundActive = false;
1124
+ }
1125
+ }
1126
+
1127
+ @SuppressWarnings("deprecation")
1128
+ private void removeForegroundNotification() {
682
1129
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
683
1130
  stopForeground(STOP_FOREGROUND_REMOVE);
684
1131
  } else {
685
1132
  stopForeground(true);
686
1133
  }
687
- mForegroundActive = false;
688
1134
  }
689
1135
 
690
1136
  /**
@@ -843,36 +1289,144 @@ public class ForegroundService extends Service {
843
1289
  stopForegroundCompat();
844
1290
  }
845
1291
 
846
- private void handleTimeout(int startId, int fgsType) {
847
- Logger.e(
848
- TAG,
849
- "Foreground service timed out (startId="
850
- + startId
851
- + ", type="
852
- + fgsType
853
- + "). Stopping service.");
1292
+ // Caller holds sLifecycleLock. Platform calls may synchronously reenter lifecycle callbacks.
1293
+ private boolean ownsTimeoutEpoch(ForegroundEpoch epoch) {
1294
+ synchronized (sLock) {
1295
+ return sInstance == this && sStateOwner == this && mForegroundEpoch == epoch;
1296
+ }
1297
+ }
854
1298
 
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)) {
860
- return;
1299
+ private void retireTimeoutEpoch(ForegroundEpoch epoch) {
1300
+ synchronized (sLock) {
1301
+ if (ownsTimeoutEpoch(epoch)) {
1302
+ mForegroundActive = false;
1303
+ clearOwnedState();
861
1304
  }
1305
+ }
1306
+ }
1307
+
1308
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
1309
+ private void handleTimeout(int startId, int fgsType, boolean shortServiceCallback) {
1310
+ Bundle notificationBundle;
1311
+ ForegroundEpoch epoch;
1312
+ RuntimeException primary = null;
1313
+ String demotionOutcome = "failed";
1314
+ String stopOutcome = "deferred";
1315
+ synchronized (sLifecycleLock) {
1316
+ final boolean stopAuthorized;
1317
+ final int stopId;
1318
+ final Object stopRequest = sStopRequest;
862
1319
  synchronized (sLock) {
863
- notifBundle = sStateOwner == this ? mCurrentNotificationBundle : null;
864
- clearOwnedState();
1320
+ epoch = mForegroundEpoch;
1321
+ int callbackType =
1322
+ shortServiceCallback ? ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE : fgsType;
1323
+ if (sInstance != this
1324
+ || sStateOwner != this
1325
+ || epoch == null
1326
+ || epoch.retiring
1327
+ || epoch.retired
1328
+ || !mForegroundActive
1329
+ || (mRunFirstStartId != 0 && startId < mRunFirstStartId)
1330
+ || callbackType <= 0
1331
+ || mCurrentForegroundServiceType <= 0
1332
+ || (callbackType & mCurrentForegroundServiceType) == 0) {
1333
+ return;
1334
+ }
1335
+ // Matching IDs are library authority only. A repeated platform promotion within the
1336
+ // same run can expose coincident metadata, so it never upgrades ambiguous provenance.
1337
+ stopAuthorized =
1338
+ !epoch.ambiguousPromotion
1339
+ && !mTimeoutStartIds.contains(startId)
1340
+ && epoch.satisfiedStartIds.contains(startId);
1341
+ stopId = epoch.lastSatisfiedStartId;
1342
+ notificationBundle =
1343
+ mCurrentNotificationBundle == null ? null : new Bundle(mCurrentNotificationBundle);
1344
+ mTimeoutStartIds.add(startId);
1345
+ epoch.retiring = true;
1346
+ }
1347
+
1348
+ // Nothing that posts an event, converts a model or retires owned evidence precedes this.
1349
+ try {
1350
+ removeForegroundNotification();
1351
+ demotionOutcome = "returned"; // Normal Binder return is not an AMS acknowledgement.
1352
+ retireTimeoutEpoch(epoch);
1353
+ } catch (RuntimeException failure) {
1354
+ primary = failure;
1355
+ }
1356
+
1357
+ if (stopAuthorized
1358
+ && ownsTimeoutEpoch(epoch)
1359
+ && epoch.runGeneration == mRunGeneration
1360
+ && epoch.runGeneration == sRunGeneration
1361
+ && sStopRequest == stopRequest
1362
+ && sPendingStarts.isEmpty()
1363
+ && mLastStartId == stopId
1364
+ && stopId != 0
1365
+ && !mStartCommandFailed
1366
+ && !mStopInitiated) {
1367
+ try {
1368
+ stopOutcome = "failed";
1369
+ StopAuthority authority = captureStopAuthority();
1370
+ boolean stopped = stopSelfResult(stopId);
1371
+ stopOutcome = stopped ? "accepted" : "refused";
1372
+ if (stopped && ownsTimeoutEpoch(epoch) && ownsStopAuthority(authority)) {
1373
+ mStopInitiated = true;
1374
+ retireTimeoutEpoch(epoch);
1375
+ }
1376
+ } catch (RuntimeException failure) {
1377
+ if (primary == null) primary = failure;
1378
+ else if (primary != failure) primary.addSuppressed(failure);
1379
+ }
865
1380
  }
866
- stopForegroundCompat();
867
- stopSelf(startId);
868
1381
  }
869
1382
 
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));
1383
+ // Consume publication authority once, including when a reentrant caller still owns locks.
1384
+ final RuntimeException timeoutFailure = primary;
1385
+ final String diagnostic =
1386
+ "Foreground timeout (startId="
1387
+ + startId
1388
+ + ", type="
1389
+ + fgsType
1390
+ + ", runGeneration="
1391
+ + epoch.runGeneration
1392
+ + ", demotion="
1393
+ + demotionOutcome
1394
+ + ", stop="
1395
+ + stopOutcome
1396
+ + ").";
1397
+ List<Runnable> publications = sTimeoutPublications.get();
1398
+ if (publications == null) {
1399
+ publications = new ArrayList<>();
1400
+ sTimeoutPublications.set(publications);
876
1401
  }
1402
+ publications.add(
1403
+ () -> {
1404
+ if (notificationBundle != null) {
1405
+ try {
1406
+ NotificationModel model = NotificationModel.fromBundle(notificationBundle);
1407
+ Bundle extras = new Bundle();
1408
+ extras.putInt("startId", startId);
1409
+ extras.putInt("fgsType", fgsType);
1410
+ EventBus.post(
1411
+ new NotificationEvent(NotificationEvent.TYPE_FG_TIMEOUT, model, extras));
1412
+ } catch (RuntimeException failure) {
1413
+ if (timeoutFailure != null && timeoutFailure != failure)
1414
+ timeoutFailure.addSuppressed(failure);
1415
+ Logger.w(TAG, "Timeout event publication failed", failure);
1416
+ }
1417
+ }
1418
+ // Preserve the existing error LogEvent, but only after the essential teardown and
1419
+ // timeout publication. Its failure cannot replace a primary platform exception.
1420
+ try {
1421
+ if (timeoutFailure == null) Logger.e(TAG, diagnostic);
1422
+ else Logger.e(TAG, diagnostic, timeoutFailure);
1423
+ } catch (RuntimeException failure) {
1424
+ if (timeoutFailure != null && timeoutFailure != failure)
1425
+ timeoutFailure.addSuppressed(failure);
1426
+ Logger.w(TAG, "Timeout diagnostic publication failed", failure);
1427
+ }
1428
+ });
1429
+ publishTimeoutEventsIfUnlocked();
1430
+ if (primary != null) throw primary;
877
1431
  }
878
1432
  }