react-native-notify-kit 10.7.1 → 10.8.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 (44) hide show
  1. package/android/src/main/java/app/notifee/core/ForegroundService.java +408 -103
  2. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +1761 -44
  3. package/android/src/test/java/app/notifee/core/NotificationManagerActionRoutingTest.java +37 -0
  4. package/dist/fcm/parseFcmPayload.d.ts +1 -0
  5. package/dist/fcm/parseFcmPayload.js.map +1 -1
  6. package/dist/fcm/reconstructNotification.js +96 -8
  7. package/dist/fcm/reconstructNotification.js.map +1 -1
  8. package/dist/validators/validateAndroidPressAction.js +3 -0
  9. package/dist/validators/validateAndroidPressAction.js.map +1 -1
  10. package/dist/validators/validateIOSNotification.js +5 -2
  11. package/dist/validators/validateIOSNotification.js.map +1 -1
  12. package/dist/version.d.ts +1 -1
  13. package/dist/version.js +1 -1
  14. package/ios/NotifeeCore/NotifeeCore.m +36 -3
  15. package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +82 -3
  16. package/ios/NotifeeCore/NotifeeCoreFcmIdentity.h +91 -0
  17. package/package.json +1 -1
  18. package/server/README.md +5 -3
  19. package/server/dist/buildPayload.d.ts.map +1 -1
  20. package/server/dist/buildPayload.js +2 -0
  21. package/server/dist/buildPayload.js.map +1 -1
  22. package/server/dist/ios.d.ts.map +1 -1
  23. package/server/dist/ios.js +8 -1
  24. package/server/dist/ios.js.map +1 -1
  25. package/server/dist/serialize.d.ts +2 -0
  26. package/server/dist/serialize.d.ts.map +1 -1
  27. package/server/dist/serialize.js +6 -0
  28. package/server/dist/serialize.js.map +1 -1
  29. package/server/dist/types.d.ts +1 -1
  30. package/server/dist/types.d.ts.map +1 -1
  31. package/server/dist/validation.d.ts.map +1 -1
  32. package/server/dist/validation.js +160 -6
  33. package/server/dist/validation.js.map +1 -1
  34. package/server/src/buildPayload.ts +2 -0
  35. package/server/src/ios.ts +8 -1
  36. package/server/src/serialize.ts +8 -0
  37. package/server/src/types.ts +1 -0
  38. package/server/src/validation.ts +187 -6
  39. package/src/fcm/parseFcmPayload.ts +1 -0
  40. package/src/fcm/reconstructNotification.ts +105 -10
  41. package/src/internal/fcmContract.d.ts +57 -12
  42. package/src/validators/validateAndroidPressAction.ts +4 -0
  43. package/src/validators/validateIOSNotification.ts +7 -2
  44. package/src/version.ts +1 -1
@@ -18,6 +18,7 @@ package app.notifee.core;
18
18
  */
19
19
 
20
20
  import android.annotation.SuppressLint;
21
+ import android.app.ForegroundServiceStartNotAllowedException;
21
22
  import android.app.Notification;
22
23
  import android.app.NotificationChannel;
23
24
  import android.app.NotificationManager;
@@ -29,7 +30,9 @@ import android.content.pm.PackageManager;
29
30
  import android.content.pm.ServiceInfo;
30
31
  import android.os.Build;
31
32
  import android.os.Bundle;
33
+ import android.os.Handler;
32
34
  import android.os.IBinder;
35
+ import android.os.Looper;
33
36
  import android.os.Trace;
34
37
  import androidx.annotation.Nullable;
35
38
  import androidx.annotation.RequiresApi;
@@ -40,6 +43,9 @@ import app.notifee.core.event.NotificationEvent;
40
43
  import app.notifee.core.interfaces.MethodCallResult;
41
44
  import app.notifee.core.model.NotificationModel;
42
45
  import app.notifee.core.utility.ParcelableCompatReader;
46
+ import java.util.HashMap;
47
+ import java.util.Map;
48
+ import java.util.UUID;
43
49
 
44
50
  public class ForegroundService extends Service {
45
51
  private static final String TAG = "ForegroundService";
@@ -49,9 +55,45 @@ public class ForegroundService extends Service {
49
55
  "app.notifee.core.ForegroundService.STOP";
50
56
 
51
57
  private static final Object sLock = new Object();
58
+ // Serializes client START acceptance with instance-targeted STOP. Notification cache fields
59
+ // are not lifecycle authority. Lock order is sLifecycleLock, then sLock.
60
+ private static final Object sLifecycleLock = new Object();
61
+ // Only START senders take this lock (the notification pipeline runs on its executor). Service
62
+ // lifecycle callbacks must never wait for a sender's Binder reply before promoting themselves.
63
+ private static final Object sStartDispatchLock = new Object();
64
+ private static final Map<String, StartRequest> sPendingStarts = new HashMap<>();
65
+ private static final String START_REQUEST_ID = "notifee.foregroundServiceStartRequestId";
66
+ private static long sRunGeneration;
67
+ private static ForegroundService sInstance;
68
+ private static Object sStopRequest;
69
+ private static ForegroundService sStateOwner;
70
+ private int mLastStartId;
71
+ private boolean mStopInitiated;
72
+ private boolean mStartCommandFailed;
73
+ private boolean mForegroundActive;
74
+ private boolean mStartCommandSatisfied;
75
+ private long mRunGeneration;
76
+ // The admission fence may advance before posting. Cached state belongs to its last successful
77
+ // generation until the replacement platform call returns normally.
78
+ private long mStateGeneration;
79
+ private int mRunFirstStartId;
80
+ private Object mRunnerToken;
52
81
  private static final String DEFENSIVE_CHANNEL_ID = "notifee_fg_default";
53
82
  private static final int DEFENSIVE_NOTIFICATION_ID = Integer.MAX_VALUE - 1;
54
83
 
84
+ private static final class StartRequest {
85
+ final long generation;
86
+ final Object precedingStop;
87
+ boolean accepted;
88
+ // Null until delivery. An unfulfilled delivered request belongs to that physical instance.
89
+ ForegroundService instance;
90
+
91
+ StartRequest(long generation, Object precedingStop) {
92
+ this.generation = generation;
93
+ this.precedingStop = precedingStop;
94
+ }
95
+ }
96
+
55
97
  /**
56
98
  * Tracks whether startForeground() has been called on THIS service instance. This is an instance
57
99
  * field (not static) because the static fields below track cross-invocation notification state
@@ -63,6 +105,8 @@ public class ForegroundService extends Service {
63
105
  */
64
106
  private volatile boolean mStartForegroundCalled = false;
65
107
 
108
+ // While active, mCurrent* tracks the latest state submitted by a platform call that returned
109
+ // normally. Android does not acknowledge that the notification was displayed.
66
110
  public static String mCurrentNotificationId = null;
67
111
 
68
112
  public static int mCurrentForegroundServiceType = -1;
@@ -71,13 +115,64 @@ public class ForegroundService extends Service {
71
115
  private static Notification mCurrentNotification = null;
72
116
  private static int mCurrentHashCode = 0;
73
117
 
118
+ @Override
119
+ public void onCreate() {
120
+ super.onCreate();
121
+ synchronized (sLifecycleLock) {
122
+ sInstance = this;
123
+ mRunGeneration = sRunGeneration;
124
+ synchronized (sLock) {
125
+ sStateOwner = this;
126
+ clearCurrentState();
127
+ }
128
+ }
129
+ }
130
+
131
+ @Override
132
+ public void onDestroy() {
133
+ synchronized (sLifecycleLock) {
134
+ sPendingStarts.values().removeIf(request -> request.instance == this);
135
+ if (sInstance == this) {
136
+ sInstance = null;
137
+ if (sPendingStarts.isEmpty()) {
138
+ sStopRequest = null;
139
+ }
140
+ }
141
+ clearOwnedState();
142
+ mForegroundActive = false;
143
+ synchronized (sLock) {
144
+ if (sStateOwner == this) {
145
+ sStateOwner = null;
146
+ }
147
+ }
148
+ }
149
+ super.onDestroy();
150
+ }
151
+
152
+ // Caller holds sLock. Ownership is checked by clearOwnedState before any terminal cleanup.
153
+ private static void clearCurrentState() {
154
+ mCurrentNotificationId = null;
155
+ mCurrentForegroundServiceType = -1;
156
+ mCurrentNotificationBundle = null;
157
+ mCurrentNotification = null;
158
+ mCurrentHashCode = 0;
159
+ }
160
+
161
+ private void clearOwnedState() {
162
+ synchronized (sLock) {
163
+ if (sStateOwner == this) {
164
+ clearCurrentState();
165
+ mRunnerToken = null;
166
+ }
167
+ }
168
+ }
169
+
74
170
  /**
75
- * Re-posts the foreground service notification if the given notification ID matches the active
76
- * foreground service. On Android 14+, users can dismiss ongoing foreground service notifications
77
- * for most service types; this method restores the notification so the user remains aware of the
78
- * running service.
171
+ * Submits the latest foreground service notification again if the given notification ID matches
172
+ * the active foreground service. On Android 14+, users can dismiss ongoing foreground service
173
+ * notifications for most service types.
79
174
  *
80
- * @return true if the notification was re-posted, false otherwise
175
+ * @return true if a re-post was submitted without a synchronous exception, false otherwise
81
176
  */
82
177
  @SuppressLint("MissingPermission")
83
178
  static boolean repostIfActive(String notificationId) {
@@ -87,7 +182,7 @@ public class ForegroundService extends Service {
87
182
  || mCurrentNotification == null) {
88
183
  return false;
89
184
  }
90
- Logger.w(TAG, "Re-posting foreground service notification dismissed by user");
185
+ Logger.w(TAG, "Submitting dismissed foreground service notification for re-post");
91
186
  NotificationManagerCompat.from(ContextHolder.getApplicationContext())
92
187
  .notify(mCurrentHashCode, mCurrentNotification);
93
188
  return true;
@@ -129,55 +224,219 @@ public class ForegroundService extends Service {
129
224
  intent.putExtra("notification", notification);
130
225
  intent.putExtra("notificationBundle", notificationBundle);
131
226
 
132
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
133
- context.startForegroundService(intent);
134
- } else {
135
- // TODO test this on older device
136
- context.startService(intent);
227
+ synchronized (sStartDispatchLock) {
228
+ // A retry/redelivery from a previous process must not alias a new pending request.
229
+ String requestId = UUID.randomUUID().toString();
230
+ intent.putExtra(START_REQUEST_ID, requestId);
231
+ StartRequest request;
232
+ synchronized (sLifecycleLock) {
233
+ request = new StartRequest(sRunGeneration, sStopRequest);
234
+ sPendingStarts.put(requestId, request);
235
+ }
236
+ ComponentName component;
237
+ try {
238
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
239
+ component = context.startForegroundService(intent);
240
+ } else {
241
+ component = context.startService(intent);
242
+ }
243
+ } catch (RuntimeException e) {
244
+ synchronized (sLifecycleLock) {
245
+ // Only a definite platform rejection releases an undelivered obligation. An unrelated
246
+ // exception can be a lost Binder reply: UNKNOWN must remain held until actual delivery.
247
+ if (!request.accepted
248
+ && (e instanceof SecurityException
249
+ || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
250
+ && e instanceof ForegroundServiceStartNotAllowedException))) {
251
+ sPendingStarts.remove(requestId);
252
+ }
253
+ scheduleStop();
254
+ }
255
+ throw e;
256
+ }
257
+ synchronized (sLifecycleLock) {
258
+ if (component != null) {
259
+ acceptStart(request);
260
+ } else if (!request.accepted) {
261
+ sPendingStarts.remove(requestId);
262
+ }
263
+ scheduleStop();
264
+ }
265
+ }
266
+ }
267
+
268
+ // Caller holds sLifecycleLock. Delivery itself can prove acceptance before Context returns.
269
+ // A late reply never cancels a STOP ordered after this START was reserved/delivered.
270
+ private static void acceptStart(StartRequest request) {
271
+ if (!request.accepted) {
272
+ request.accepted = true;
273
+ if (sStopRequest == request.precedingStop) {
274
+ sStopRequest = null;
275
+ }
137
276
  }
138
277
  }
139
278
 
140
279
  static void stop() {
141
- Context context = ContextHolder.getApplicationContext();
142
- if (context == null) {
143
- Logger.e(TAG, "Application context is null; cannot stop ForegroundService.");
144
- return;
280
+ synchronized (sLifecycleLock) {
281
+ if (sInstance == null && sPendingStarts.isEmpty()) {
282
+ return;
283
+ }
284
+ sStopRequest = new Object();
285
+ sRunGeneration++;
286
+ scheduleStop();
145
287
  }
288
+ }
146
289
 
147
- Intent intent = new Intent(context, ForegroundService.class);
148
- intent.setAction(STOP_FOREGROUND_SERVICE_ACTION);
290
+ // Caller holds sLifecycleLock. No START/STOP intent is dispatched here, so a stale reference
291
+ // cannot cold-start a service. onCreate alone is UNKNOWN: wait for a delivered command.
292
+ private static void scheduleStop() {
293
+ ForegroundService instance = sInstance;
294
+ Object request = sStopRequest;
295
+ if (instance != null && request != null) {
296
+ new Handler(Looper.getMainLooper()).post(() -> instance.stopIfRequested(request));
297
+ }
298
+ }
149
299
 
150
- try {
151
- // Call start service first with stop action
152
- context.startService(intent);
153
- } catch (IllegalStateException illegalStateException) {
154
- Logger.w(
155
- TAG,
156
- "startService() threw IllegalStateException on STOP path;"
157
- + " falling back to stopService()",
158
- illegalStateException);
159
- // try to stop with stopService command
160
- context.stopService(intent);
161
- } catch (Exception exception) {
162
- Logger.e(TAG, "Unable to stop foreground service", exception);
300
+ private void stopIfRequested(Object request) {
301
+ synchronized (sLifecycleLock) {
302
+ if (sInstance != this
303
+ || sStopRequest != request
304
+ || !sPendingStarts.isEmpty()
305
+ || mLastStartId == 0
306
+ || mStopInitiated
307
+ || mStartCommandFailed) {
308
+ return;
309
+ }
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();
316
+ }
163
317
  }
164
318
  }
165
319
 
166
- @SuppressLint({"ForegroundServiceType", "MissingPermission"})
167
320
  @Override
168
321
  public int onStartCommand(Intent intent, int flags, int startId) {
322
+ synchronized (sLifecycleLock) {
323
+ mLastStartId = startId;
324
+ boolean terminal =
325
+ intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction());
326
+ if (terminal) {
327
+ sRunGeneration++;
328
+ }
329
+ if (terminal && !sPendingStarts.isEmpty()) {
330
+ // The earlier accepted START must promote with its real notification/type first.
331
+ sStopRequest = new Object();
332
+ return START_STICKY_COMPATIBILITY;
333
+ }
334
+ if (!terminal) {
335
+ mStopInitiated = false;
336
+ }
337
+ String requestId = intent == null ? null : intent.getStringExtra(START_REQUEST_ID);
338
+ StartRequest request = sPendingStarts.get(requestId);
339
+ boolean superseded = false;
340
+ if (!terminal) {
341
+ long generation = request == null ? sRunGeneration : request.generation;
342
+ if (request != null) {
343
+ request.instance = this;
344
+ acceptStart(request);
345
+ }
346
+ superseded = mRunFirstStartId != 0 && generation < mRunGeneration;
347
+ if (!superseded && mRunGeneration != generation) {
348
+ // Reserve the new session without retiring the previous submitted state or runner.
349
+ // A failed promotion must preserve them, while older deliveries remain fenced.
350
+ mRunGeneration = generation;
351
+ }
352
+ }
353
+ mStartCommandSatisfied = mForegroundActive;
354
+ try {
355
+ int result;
356
+ if (superseded) {
357
+ // A lost-reply START can arrive after a newer run. Preserve the new cache/runner. If
358
+ // that run already ended, still perform the old request's real promotion before teardown.
359
+ result = mForegroundActive ? START_NOT_STICKY : fulfillSupersededStart(intent);
360
+ } else {
361
+ result = handleStartCommand(intent, flags, startId);
362
+ }
363
+ mStartCommandFailed = false;
364
+ if (mStartCommandSatisfied) {
365
+ settleDeliveredStarts();
366
+ }
367
+ if (superseded
368
+ && mStartCommandSatisfied
369
+ && !mForegroundActive
370
+ && mRunnerToken == null
371
+ && sPendingStarts.isEmpty()
372
+ && sStopRequest == null) {
373
+ sStopRequest = new Object();
374
+ }
375
+ if (!terminal) {
376
+ scheduleStop();
377
+ }
378
+ return result;
379
+ } catch (RuntimeException e) {
380
+ mStartCommandFailed = !mStartCommandSatisfied;
381
+ if (mStartCommandSatisfied) {
382
+ settleDeliveredStarts();
383
+ }
384
+ throw e;
385
+ }
386
+ }
387
+ }
388
+
389
+ // A real promotion (or an already foreground instance) satisfies the physical instance's
390
+ // delivered obligations, including a prior ambiguous delivery. Undelivered STARTs remain held.
391
+ // This does not turn a posting/type exception into success: onStartCommand still rethrows it.
392
+ private void settleDeliveredStarts() {
393
+ sPendingStarts.values().removeIf(request -> request.instance == this);
394
+ }
395
+
396
+ @SuppressLint({"ForegroundServiceType", "MissingPermission"})
397
+ private int fulfillSupersededStart(Intent intent) {
398
+ Bundle extras = intent.getExtras();
399
+ if (extras == null) {
400
+ return START_NOT_STICKY;
401
+ }
402
+ Notification notification =
403
+ ParcelableCompatReader.getParcelable(extras, "notification", Notification.class);
404
+ Bundle bundle = extras.getBundle("notificationBundle");
405
+ if (notification == null || bundle == null) {
406
+ return START_NOT_STICKY;
407
+ }
408
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
409
+ int type = NotificationModel.fromBundle(bundle).getAndroid().getForegroundServiceType();
410
+ if (type == ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) {
411
+ type = resolveManifestServiceType();
412
+ }
413
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
414
+ && type == ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE) {
415
+ ensureStartForegroundContractSatisfied(false);
416
+ return START_NOT_STICKY;
417
+ }
418
+ startForeground(extras.getInt("hashCode"), notification, type);
419
+ } else {
420
+ startForeground(extras.getInt("hashCode"), notification);
421
+ }
422
+ mStartForegroundCalled = true;
423
+ mForegroundActive = true;
424
+ mStartCommandSatisfied = true;
425
+ stopForegroundCompat();
426
+ return START_NOT_STICKY;
427
+ }
428
+
429
+ @SuppressLint({"ForegroundServiceType", "MissingPermission"})
430
+ private int handleStartCommand(Intent intent, int flags, int startId) {
169
431
  Trace.beginSection("notifee:ForegroundService.onStartCommand");
170
432
  try {
171
433
  // Check if action is to stop the foreground service
172
434
  if (intent == null || STOP_FOREGROUND_SERVICE_ACTION.equals(intent.getAction())) {
173
- ensureStartForegroundContractSatisfied();
174
- stopSelf();
175
- synchronized (sLock) {
176
- mCurrentNotificationId = null;
177
- mCurrentForegroundServiceType = -1;
178
- mCurrentNotificationBundle = null;
179
- mCurrentNotification = null;
180
- mCurrentHashCode = 0;
435
+ ensureStartForegroundContractSatisfied(true);
436
+ if (stopSelfResult(startId)) {
437
+ mStopInitiated = true;
438
+ mForegroundActive = false;
439
+ clearOwnedState();
181
440
  }
182
441
  return Service.START_STICKY_COMPATIBILITY;
183
442
  }
@@ -193,17 +452,13 @@ public class ForegroundService extends Service {
193
452
 
194
453
  if (notification != null && bundle != null) {
195
454
  NotificationModel notificationModel = NotificationModel.fromBundle(bundle);
455
+ String notificationId = notificationModel.getId();
196
456
 
197
457
  Object pendingEvent = null;
198
458
  boolean noneEarlyReturn = false;
199
459
 
200
460
  synchronized (sLock) {
201
- if (mCurrentNotificationId == null) {
202
- mCurrentNotificationId = notificationModel.getId();
203
- mCurrentNotificationBundle = bundle;
204
- mCurrentNotification = notification;
205
- mCurrentHashCode = hashCode;
206
-
461
+ if (mCurrentNotificationId == null || mStateGeneration != mRunGeneration) {
207
462
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
208
463
  int foregroundServiceType =
209
464
  notificationModel.getAndroid().getForegroundServiceType();
@@ -216,12 +471,6 @@ public class ForegroundService extends Service {
216
471
  TAG,
217
472
  "Resolved foreground service type is NONE on API 34+; aborting"
218
473
  + " startForeground to avoid InvalidForegroundServiceTypeException.");
219
- // Reset stale state while still holding the lock.
220
- mCurrentNotificationId = null;
221
- mCurrentForegroundServiceType = -1;
222
- mCurrentNotificationBundle = null;
223
- mCurrentNotification = null;
224
- mCurrentHashCode = 0;
225
474
  // Set flag to call the helper AFTER releasing sLock — it performs
226
475
  // Binder IPC (PackageManager + startForeground) and must not hold sLock.
227
476
  noneEarlyReturn = true;
@@ -233,6 +482,8 @@ public class ForegroundService extends Service {
233
482
  Trace.endSection();
234
483
  }
235
484
  mStartForegroundCalled = true;
485
+ mForegroundActive = true;
486
+ mStartCommandSatisfied = true;
236
487
  mCurrentForegroundServiceType = foregroundServiceType;
237
488
  }
238
489
  } else {
@@ -243,56 +494,90 @@ public class ForegroundService extends Service {
243
494
  Trace.endSection();
244
495
  }
245
496
  mStartForegroundCalled = true;
497
+ mForegroundActive = true;
498
+ mStartCommandSatisfied = true;
246
499
  }
247
500
 
248
501
  if (!noneEarlyReturn) {
502
+ mCurrentNotificationId = notificationId;
503
+ mCurrentNotificationBundle = bundle;
504
+ mCurrentNotification = notification;
505
+ mCurrentHashCode = hashCode;
506
+ mStateGeneration = mRunGeneration;
507
+ mRunFirstStartId = mLastStartId;
508
+ final Object runnerToken = new Object();
509
+ final long runnerGeneration = mRunGeneration;
510
+ mRunnerToken = runnerToken;
249
511
  // On headless task complete
250
512
  final MethodCallResult<Void> methodCallResult =
251
513
  (e, aVoid) -> {
252
- stopForegroundCompat();
253
- synchronized (sLock) {
254
- mCurrentNotificationId = null;
255
- mCurrentForegroundServiceType = -1;
256
- mCurrentNotificationBundle = null;
257
- mCurrentNotification = null;
258
- mCurrentHashCode = 0;
514
+ Runnable complete =
515
+ () -> {
516
+ synchronized (sLifecycleLock) {
517
+ if (sInstance == this
518
+ && mRunnerToken == runnerToken
519
+ && sRunGeneration == runnerGeneration) {
520
+ stopForegroundCompat();
521
+ clearOwnedState();
522
+ }
523
+ }
524
+ };
525
+ if (Looper.myLooper() == Looper.getMainLooper()) {
526
+ complete.run();
527
+ } else {
528
+ new Handler(Looper.getMainLooper()).post(complete);
259
529
  }
260
530
  };
261
531
 
262
532
  pendingEvent = new ForegroundServiceEvent(notificationModel, methodCallResult);
263
533
  }
264
534
  } else {
265
- if (mCurrentNotificationId.equals(notificationModel.getId())) {
266
- boolean shouldPostNotificationAgain = true;
267
- // find if we need to start the service again if the type was changed
535
+ if (mCurrentNotificationId.equals(notificationId)) {
536
+ // Before API 31, updating the service's registered notification requires
537
+ // startForeground(). API 31+ processes a same-ID notify() against the foreground
538
+ // service record. Keep that path for an unchanged type: repeating
539
+ // startForeground() can extend a shortService timeout on API 34+.
540
+ boolean shouldStartForeground = Build.VERSION.SDK_INT < Build.VERSION_CODES.S;
541
+ int foregroundServiceType = mCurrentForegroundServiceType;
268
542
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
269
- int foregroundServiceType =
270
- notificationModel.getAndroid().getForegroundServiceType();
271
- if (foregroundServiceType == ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) {
272
- foregroundServiceType = resolveManifestServiceType();
543
+ int requestedType = notificationModel.getAndroid().getForegroundServiceType();
544
+ if (requestedType == ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) {
545
+ requestedType = resolveManifestServiceType();
273
546
  }
274
547
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
275
- && foregroundServiceType == ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE) {
548
+ && requestedType == ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE) {
276
549
  Logger.e(
277
550
  TAG,
278
551
  "Resolved foreground service type is NONE on API 34+; skipping type"
279
552
  + " change.");
280
- } else if (foregroundServiceType != mCurrentForegroundServiceType) {
281
- Trace.beginSection("notifee:startForeground");
282
- try {
283
- startForeground(hashCode, notification, foregroundServiceType);
284
- } finally {
285
- Trace.endSection();
286
- }
287
- mStartForegroundCalled = true;
288
- mCurrentForegroundServiceType = foregroundServiceType;
289
- shouldPostNotificationAgain = false;
553
+ } else if (requestedType != mCurrentForegroundServiceType) {
554
+ foregroundServiceType = requestedType;
555
+ shouldStartForeground = true;
290
556
  }
291
557
  }
292
- if (shouldPostNotificationAgain) {
558
+ if (shouldStartForeground) {
559
+ Trace.beginSection("notifee:startForeground");
560
+ try {
561
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
562
+ startForeground(mCurrentHashCode, notification, foregroundServiceType);
563
+ } else {
564
+ startForeground(mCurrentHashCode, notification);
565
+ }
566
+ } finally {
567
+ Trace.endSection();
568
+ }
569
+ mStartForegroundCalled = true;
570
+ mForegroundActive = true;
571
+ mStartCommandSatisfied = true;
572
+ } else {
293
573
  NotificationManagerCompat.from(ContextHolder.getApplicationContext())
294
- .notify(hashCode, notification);
574
+ .notify(mCurrentHashCode, notification);
295
575
  }
576
+ // The platform call returned normally, so cache the latest submitted state for
577
+ // re-post and timeout. A normal return does not acknowledge display.
578
+ mCurrentForegroundServiceType = foregroundServiceType;
579
+ mCurrentNotification = notification;
580
+ mCurrentNotificationBundle = bundle;
296
581
  } else {
297
582
  pendingEvent =
298
583
  new NotificationEvent(
@@ -304,7 +589,7 @@ public class ForegroundService extends Service {
304
589
  // Handle NONE early return outside the lock — the helper performs Binder IPC
305
590
  // (PackageManager query + startForeground) that must not hold sLock.
306
591
  if (noneEarlyReturn) {
307
- ensureStartForegroundContractSatisfied();
592
+ ensureStartForegroundContractSatisfied(false);
308
593
  return START_NOT_STICKY;
309
594
  }
310
595
 
@@ -399,6 +684,7 @@ public class ForegroundService extends Service {
399
684
  } else {
400
685
  stopForeground(true);
401
686
  }
687
+ mForegroundActive = false;
402
688
  }
403
689
 
404
690
  /**
@@ -412,7 +698,7 @@ public class ForegroundService extends Service {
412
698
  * on API 29-33 and to {@code FOREGROUND_SERVICE_TYPE_NONE} on API 34+. This method is an instance
413
699
  * method that uses {@code this} as context (always valid inside a running Service) and returns
414
700
  * the raw declared value without any API-level mapping, which is what the proactive manifest
415
- * check in {@link #ensureStartForegroundContractSatisfied()} needs.
701
+ * check in {@link #ensureStartForegroundContractSatisfied(boolean)} needs.
416
702
  *
417
703
  * @return the raw {@code foregroundServiceType} bitmask from the manifest, or 0 if the service is
418
704
  * not declared or has no explicit type
@@ -477,10 +763,12 @@ public class ForegroundService extends Service {
477
763
  * @throws RuntimeException if the manifest is missing required {@code foregroundServiceType}
478
764
  * declarations on API 34+, or if the defensive {@code startForeground()} call fails for a
479
765
  * non-platform-permission reason. {@link SecurityException} is handled as a best-effort stop
480
- * because API 34+ can deny while-in-use types when the app is backgrounded.
766
+ * because API 34+ can deny while-in-use types when the app is backgrounded. A terminal
767
+ * STOP/null also logs and tolerates the API 31+ promotion denial specifically; that policy
768
+ * does not apply to NONE or to an earlier unfulfilled accepted START.
481
769
  */
482
770
  @SuppressLint({"ForegroundServiceType", "MissingPermission"})
483
- private void ensureStartForegroundContractSatisfied() {
771
+ private void ensureStartForegroundContractSatisfied(boolean terminalStop) {
484
772
  if (mStartForegroundCalled) {
485
773
  return;
486
774
  }
@@ -508,36 +796,51 @@ public class ForegroundService extends Service {
508
796
  }
509
797
  }
510
798
 
799
+ ensureDefensiveChannel();
800
+ Notification placeholder =
801
+ new NotificationCompat.Builder(this, DEFENSIVE_CHANNEL_ID)
802
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
803
+ .build();
804
+ // Resolve the safe declared type before entering the promotion-only exception boundary.
805
+ int defensiveTypes =
806
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
807
+ ? selectDefensiveForegroundServiceType(declaredTypes)
808
+ : 0;
511
809
  try {
512
- ensureDefensiveChannel();
513
- Notification placeholder =
514
- new NotificationCompat.Builder(this, DEFENSIVE_CHANNEL_ID)
515
- .setSmallIcon(android.R.drawable.ic_dialog_info)
516
- .build();
517
810
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
518
- // The STOP/null defensive placeholder does not represent the real workload. Prefer a
519
- // declared type without while-in-use runtime restrictions before falling back to the
520
- // manifest-declared mask.
521
- int defensiveTypes = selectDefensiveForegroundServiceType(declaredTypes);
522
811
  startForeground(DEFENSIVE_NOTIFICATION_ID, placeholder, defensiveTypes);
523
812
  } else {
524
813
  startForeground(DEFENSIVE_NOTIFICATION_ID, placeholder);
525
814
  }
526
- mStartForegroundCalled = true;
527
- stopForegroundCompat();
528
815
  } catch (SecurityException e) {
529
816
  Logger.w(
530
817
  TAG,
531
818
  "Defensive startForeground() was denied by Android while stopping the service; "
532
819
  + "continuing with stopSelf() to avoid a fatal app crash.",
533
820
  e);
821
+ return;
534
822
  } catch (Exception e) {
823
+ // NONE and unrelated IllegalStateException/type failures remain observable.
824
+ if (terminalStop
825
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
826
+ && e instanceof ForegroundServiceStartNotAllowedException) {
827
+ Logger.w(
828
+ TAG,
829
+ "Defensive startForeground() was not allowed by Android while stopping; "
830
+ + "continuing with stopSelf().",
831
+ e);
832
+ return;
833
+ }
535
834
  String msg =
536
835
  "react-native-notify-kit: defensive startForeground() failed. "
537
836
  + "This indicates an inconsistent service state and the process cannot recover.";
538
837
  Logger.e(TAG, msg, e);
539
838
  throw new RuntimeException(msg, e);
540
839
  }
840
+ mStartForegroundCalled = true;
841
+ mForegroundActive = true;
842
+ mStartCommandSatisfied = true;
843
+ stopForegroundCompat();
541
844
  }
542
845
 
543
846
  private void handleTimeout(int startId, int fgsType) {
@@ -550,18 +853,20 @@ public class ForegroundService extends Service {
550
853
  + "). Stopping service.");
551
854
 
552
855
  Bundle notifBundle;
553
- synchronized (sLock) {
554
- notifBundle = mCurrentNotificationBundle;
555
- mCurrentNotificationId = null;
556
- mCurrentForegroundServiceType = -1;
557
- mCurrentNotificationBundle = null;
558
- mCurrentNotification = null;
559
- mCurrentHashCode = 0;
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;
861
+ }
862
+ synchronized (sLock) {
863
+ notifBundle = sStateOwner == this ? mCurrentNotificationBundle : null;
864
+ clearOwnedState();
865
+ }
866
+ stopForegroundCompat();
867
+ stopSelf(startId);
560
868
  }
561
869
 
562
- stopForegroundCompat();
563
- stopSelf(startId);
564
-
565
870
  if (notifBundle != null) {
566
871
  NotificationModel model = NotificationModel.fromBundle(notifBundle);
567
872
  Bundle extras = new Bundle();