react-native-notify-kit 10.8.1 → 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 +33 -18
  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 +395 -5
  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 +23 -13
  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 +12 -1
  40. package/dist/types/Notification.js +3 -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 +13 -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
@@ -0,0 +1,461 @@
1
+ package app.notifee.core;
2
+
3
+ import android.app.AlarmManager;
4
+ import android.app.Application;
5
+ import android.app.PendingIntent;
6
+ import android.content.ComponentName;
7
+ import android.content.Context;
8
+ import android.content.Intent;
9
+ import android.content.SharedPreferences;
10
+ import android.content.pm.ActivityInfo;
11
+ import android.content.pm.ApplicationInfo;
12
+ import android.content.pm.PackageManager;
13
+ import android.content.pm.ServiceInfo;
14
+ import android.net.Uri;
15
+ import android.os.Build;
16
+ import android.os.SystemClock;
17
+ import android.provider.Settings;
18
+ import android.util.Log;
19
+ import java.util.HashSet;
20
+ import java.util.Set;
21
+ import java.util.UUID;
22
+ import org.json.JSONException;
23
+ import org.json.JSONObject;
24
+
25
+ /** Internal opt-in run limit. Persistence never grants lifecycle authority. */
26
+ final class ForegroundServiceRunLimitScheduler {
27
+ static final String ACTION = "app.notifee.core.FGS_RUN_LIMIT_EXPIRE";
28
+ static final String PROCESS = UUID.randomUUID().toString();
29
+ static final String PREFS = "notifee-fgs-run-limit";
30
+ static final int SCHEMA = 1;
31
+ // Index only new tickets. Never enumerate/adopt legacy or unindexed storage.
32
+ static final int GC_SLOTS = 32;
33
+ static final int GC_BATCH = 8;
34
+ private static final Object GC_LOCK = new Object();
35
+ private static final boolean[] RESERVED = new boolean[GC_SLOTS];
36
+ private static final Set<String> LIVE_KEYS = new HashSet<>();
37
+ private static int gcCursor;
38
+ private static boolean gcRunning;
39
+
40
+ static final class Request {
41
+ final String run;
42
+ final long duration;
43
+ final boolean publicOption;
44
+
45
+ private Request(long duration) {
46
+ this.run = UUID.randomUUID().toString();
47
+ this.duration = duration;
48
+ this.publicOption = true;
49
+ }
50
+
51
+ Request(Context context, String run, long duration) {
52
+ if (run == null || !run.matches("[a-zA-Z0-9_-]{1,80}") || duration < 1 || duration > 600000) {
53
+ throw new IllegalArgumentException(
54
+ "Explicit diagnostic run and duration (1..600000ms) required");
55
+ }
56
+ if (!available(context)) throw new IllegalStateException("Smoke debug receiver unavailable");
57
+ this.run = run;
58
+ this.duration = duration;
59
+ this.publicOption = false;
60
+ // Diagnostic fixture retains its reviewed admission-time sweep.
61
+ collectOrphans(context);
62
+ }
63
+ }
64
+
65
+ static Request publicRequest(Context context, long duration, int effectiveType) {
66
+ if (effectiveType != ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
67
+ || !available(context, false)) {
68
+ throw new IllegalArgumentException(
69
+ "notification.android.foregroundServiceBestEffortStopAfterMs requires single dataSync and"
70
+ + " an internal receiver in the app FGS process (API 35+)");
71
+ }
72
+ return new Request(duration);
73
+ }
74
+
75
+ static boolean available(Context context) {
76
+ return available(context, true);
77
+ }
78
+
79
+ private static boolean available(Context context, boolean diagnostic) {
80
+ if (context == null
81
+ || Build.VERSION.SDK_INT < 35
82
+ || (diagnostic
83
+ && (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0))
84
+ return false;
85
+ try {
86
+ ActivityInfo receiver =
87
+ context
88
+ .getPackageManager()
89
+ .getReceiverInfo(
90
+ new ComponentName(context, ForegroundServiceRunLimitReceiver.class), 0);
91
+ ServiceInfo service =
92
+ context
93
+ .getPackageManager()
94
+ .getServiceInfo(new ComponentName(context, ForegroundService.class), 0);
95
+ return (service.getForegroundServiceType() & ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
96
+ != 0
97
+ && receiver.enabled
98
+ && !receiver.exported
99
+ && context.getPackageName().equals(receiver.packageName)
100
+ && java.util.Objects.equals(receiver.processName, service.processName)
101
+ && (diagnostic || context.getApplicationInfo().processName.equals(service.processName));
102
+ } catch (PackageManager.NameNotFoundException | RuntimeException failure) {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ static int boot(Context context) {
108
+ return Settings.Global.getInt(context.getContentResolver(), Settings.Global.BOOT_COUNT, -1);
109
+ }
110
+
111
+ static final class Ticket {
112
+ final ForegroundService owner;
113
+ final Object epoch;
114
+ final long generation;
115
+ final long runGeneration;
116
+ final long deadline;
117
+ final long duration;
118
+ final boolean publicOption;
119
+ final int boot;
120
+ final String token = UUID.randomUUID().toString();
121
+ // Physical ownership: even a delayed/reentrant editor for A cannot address B's keys.
122
+ final String recordKey = "ticket/" + PROCESS + "/" + token;
123
+ final String stateKey = recordKey + "/state";
124
+ final String wire;
125
+ final Intent intent;
126
+ PendingIntent pendingIntent;
127
+ boolean valid = true;
128
+ boolean scheduled;
129
+
130
+ @androidx.annotation.RequiresApi(35)
131
+ Ticket(
132
+ ForegroundService owner,
133
+ Object epoch,
134
+ long generation,
135
+ long runGeneration,
136
+ Request request,
137
+ long promotedElapsed)
138
+ throws JSONException {
139
+ this.owner = owner;
140
+ this.epoch = epoch;
141
+ this.generation = generation;
142
+ this.runGeneration = runGeneration;
143
+ duration = request.duration;
144
+ publicOption = request.publicOption;
145
+ deadline = Math.addExact(promotedElapsed, request.duration);
146
+ boot = boot(owner);
147
+ if (boot < 0) throw new IllegalStateException("Boot identity unavailable");
148
+ wire =
149
+ new JSONObject()
150
+ .put("schema", SCHEMA)
151
+ .put("boot", boot)
152
+ .put("process", PROCESS)
153
+ .put("instance", owner.alarmPocInstanceId())
154
+ .put("run", request.run)
155
+ .put("epoch", UUID.randomUUID().toString())
156
+ .put("generation", generation)
157
+ .put("runGeneration", runGeneration)
158
+ .put("token", token)
159
+ .put("firstSuccessfulPromotionElapsed", promotedElapsed)
160
+ .put("deadlineElapsed", deadline)
161
+ .put("type", ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
162
+ .toString();
163
+ intent =
164
+ new Intent(owner, ForegroundServiceRunLimitReceiver.class)
165
+ .setAction(ACTION)
166
+ // Intent identity includes data; notification hashes and extras are never identity.
167
+ .setData(Uri.parse("notifykit-fgs-run-limit://expiry/" + PROCESS + "/" + token))
168
+ .putExtra("schema", SCHEMA)
169
+ .putExtra("token", token)
170
+ .putExtra("record", wire);
171
+ }
172
+ }
173
+
174
+ private static SharedPreferences ledger(Context context) {
175
+ return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
176
+ }
177
+
178
+ private static void write(Ticket ticket, String state, int slot) {
179
+ SharedPreferences.Editor editor = ledger(ticket.owner).edit();
180
+ if ("ARMING".equals(state)) editor.putString("gc/slot/" + slot, ticket.recordKey);
181
+ if (!editor
182
+ .putString(ticket.recordKey, ticket.wire)
183
+ .putString(ticket.stateKey, state)
184
+ .commit()) {
185
+ throw new IllegalStateException("Ledger commit refused");
186
+ }
187
+ }
188
+
189
+ static boolean arm(Ticket ticket) {
190
+ String phase = "STORAGE_FAILED";
191
+ int slot = -1;
192
+ try {
193
+ synchronized (GC_LOCK) {
194
+ LIVE_KEYS.add(ticket.recordKey);
195
+ }
196
+ slot = reserveEmptySlot(ticket.owner);
197
+ write(ticket, "ARMING", slot);
198
+ if (!ticket.valid) return false;
199
+ phase = "SCHEDULE_FAILED";
200
+ ticket.pendingIntent =
201
+ PendingIntent.getBroadcast(ticket.owner, 0, ticket.intent, PendingIntent.FLAG_IMMUTABLE);
202
+ ticket
203
+ .owner
204
+ .getSystemService(AlarmManager.class)
205
+ .setAndAllowWhileIdle(
206
+ AlarmManager.ELAPSED_REALTIME_WAKEUP, ticket.deadline, ticket.pendingIntent);
207
+ if (!ticket.valid) return false;
208
+ phase = "STORAGE_FAILED";
209
+ write(ticket, "ARMED", slot);
210
+ if (!ticket.valid) return false;
211
+ ticket.scheduled = true;
212
+ marker("ALARM_ARMED", ticket, "requestedDurationIsVoluntary=true");
213
+ return true;
214
+ } catch (RuntimeException failure) {
215
+ marker(phase, ticket, failure.toString());
216
+ return false;
217
+ } finally {
218
+ if (slot >= 0) releaseSlot(slot);
219
+ if (!ticket.scheduled) cancel(ticket);
220
+ }
221
+ }
222
+
223
+ static boolean matches(Ticket ticket, Intent intent) {
224
+ try {
225
+ return ticket.valid
226
+ && ticket.scheduled
227
+ && boot(ticket.owner) == ticket.boot
228
+ && intent != null
229
+ && ticket.intent.filterEquals(intent)
230
+ && intent.getIntExtra("schema", -1) == SCHEMA
231
+ && ticket.token.equals(intent.getStringExtra("token"))
232
+ && ticket.wire.equals(intent.getStringExtra("record"));
233
+ } catch (RuntimeException malformed) {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ static boolean ledgerMatches(Ticket ticket) {
239
+ try {
240
+ SharedPreferences prefs = ledger(ticket.owner);
241
+ return ticket.wire.equals(prefs.getString(ticket.recordKey, null))
242
+ && "ARMED".equals(prefs.getString(ticket.stateKey, null));
243
+ } catch (RuntimeException failure) {
244
+ marker("STORAGE_FAILED", ticket, failure.toString());
245
+ return false;
246
+ }
247
+ }
248
+
249
+ // Caller has already revoked the live authority. Cancellation cannot revoke an in-flight send.
250
+ static void cancel(Ticket ticket) {
251
+ ticket.valid = false;
252
+ synchronized (GC_LOCK) {
253
+ LIVE_KEYS.remove(ticket.recordKey);
254
+ }
255
+ if (ticket.pendingIntent != null) {
256
+ try {
257
+ ticket.owner.getSystemService(AlarmManager.class).cancel(ticket.pendingIntent);
258
+ } catch (RuntimeException failure) {
259
+ marker("CANCEL_FAILED", ticket, failure.toString());
260
+ }
261
+ try {
262
+ ticket.pendingIntent.cancel();
263
+ } catch (RuntimeException failure) {
264
+ marker("CANCEL_FAILED", ticket, failure.toString());
265
+ }
266
+ }
267
+ try {
268
+ SharedPreferences prefs = ledger(ticket.owner);
269
+ // The comparison rejects corrupt records; it is not a CAS. Safety comes from the
270
+ // immutable ticket-specific keys at commit, including reentrancy inside remove/commit.
271
+ // Unindexed legacy/global orphans stay inert: never adopt or bulk-delete them.
272
+ if (ticket.wire.equals(prefs.getString(ticket.recordKey, null))
273
+ && !prefs.edit().remove(ticket.recordKey).remove(ticket.stateKey).commit()) {
274
+ marker("STORAGE_FAILED", ticket, "cancellation commit refused");
275
+ }
276
+ } catch (RuntimeException failure) {
277
+ marker("STORAGE_FAILED", ticket, failure.toString());
278
+ }
279
+ }
280
+
281
+ private static boolean reserveSlot(int slot) {
282
+ synchronized (GC_LOCK) {
283
+ if (RESERVED[slot]) return false;
284
+ RESERVED[slot] = true;
285
+ return true;
286
+ }
287
+ }
288
+
289
+ private static void releaseSlot(int slot) {
290
+ synchronized (GC_LOCK) {
291
+ RESERVED[slot] = false;
292
+ }
293
+ }
294
+
295
+ private static boolean liveKey(String key) {
296
+ synchronized (GC_LOCK) {
297
+ return LIVE_KEYS.contains(key);
298
+ }
299
+ }
300
+
301
+ private static final String UUID_PATTERN = "[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}";
302
+
303
+ private static boolean nonnegativeInteger(Object value) {
304
+ return (value instanceof Integer || value instanceof Long) && ((Number) value).longValue() >= 0;
305
+ }
306
+
307
+ private static boolean ticketKey(String key) {
308
+ return key != null && key.matches("ticket/" + UUID_PATTERN + "/" + UUID_PATTERN);
309
+ }
310
+
311
+ private static int reserveEmptySlot(Context context) {
312
+ SharedPreferences prefs = ledger(context);
313
+ for (int slot = 0; slot < GC_SLOTS; slot++) {
314
+ if (!reserveSlot(slot)) continue;
315
+ boolean claimed = false;
316
+ try {
317
+ String key = prefs.getString("gc/slot/" + slot, null);
318
+ if (key == null
319
+ || (ticketKey(key)
320
+ && !liveKey(key)
321
+ && !prefs.contains(key)
322
+ && !prefs.contains(key + "/state"))) {
323
+ claimed = true;
324
+ return slot;
325
+ }
326
+ } finally {
327
+ if (!claimed) releaseSlot(slot);
328
+ }
329
+ }
330
+ marker("GC_CAPACITY", null, "32 slots occupied/ambiguous; diagnostic protection not armed");
331
+ throw new IllegalStateException("Diagnostic ledger index full; preserving existing records");
332
+ }
333
+
334
+ @androidx.annotation.RequiresApi(35)
335
+ private static boolean orphanRecord(String key, String wire, String state) {
336
+ if (!ticketKey(key)
337
+ || wire == null
338
+ || wire.length() > 2048
339
+ || !("ARMING".equals(state) || "ARMED".equals(state))) return false;
340
+ try {
341
+ JSONObject record = new JSONObject(wire);
342
+ String process = record.getString("process");
343
+ String token = record.getString("token");
344
+ return Integer.valueOf(SCHEMA).equals(record.get("schema"))
345
+ && record.get("boot") instanceof Integer
346
+ && record.getInt("boot") >= 0
347
+ && key.equals("ticket/" + process + "/" + token)
348
+ && record.getString("instance").matches(UUID_PATTERN)
349
+ && record.getString("epoch").matches(UUID_PATTERN)
350
+ && record.getString("run").matches("[a-zA-Z0-9_-]{1,80}")
351
+ && nonnegativeInteger(record.get("generation"))
352
+ && nonnegativeInteger(record.get("runGeneration"))
353
+ && nonnegativeInteger(record.get("firstSuccessfulPromotionElapsed"))
354
+ && nonnegativeInteger(record.get("deadlineElapsed"))
355
+ && record.getLong("deadlineElapsed") > record.getLong("firstSuccessfulPromotionElapsed")
356
+ && record.getLong("deadlineElapsed") - record.getLong("firstSuccessfulPromotionElapsed")
357
+ <= 18000000
358
+ && Integer.valueOf(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
359
+ .equals(record.get("type"))
360
+ // Another incarnation can never own a ticket here. For this incarnation, only
361
+ // absence from the native arm registry proves non-ownership; age is irrelevant.
362
+ && (!PROCESS.equals(process) || !liveKey(key));
363
+ } catch (JSONException | RuntimeException ambiguous) {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ static void collectOrphans(Context context) {
369
+ // A different configured Service process may still be alive. Its PROCESS UUID is not
370
+ // proof of a previous incarnation. Admission runs outside lifecycle locks, so verify
371
+ // the manifest process before touching storage. Unknown process identity keeps data.
372
+ try {
373
+ if (Build.VERSION.SDK_INT < 35
374
+ || !Application.getProcessName()
375
+ .equals(
376
+ context
377
+ .getPackageManager()
378
+ .getServiceInfo(new ComponentName(context, ForegroundService.class), 0)
379
+ .processName)) {
380
+ marker("GC_RESULT", null, "examined=0 skipped=service_process_mismatch");
381
+ return;
382
+ }
383
+ } catch (PackageManager.NameNotFoundException | RuntimeException unknown) {
384
+ marker("GC_RESULT", null, "examined=0 skipped=service_process_unknown");
385
+ return;
386
+ }
387
+ int first;
388
+ synchronized (GC_LOCK) {
389
+ if (gcRunning) return;
390
+ gcRunning = true;
391
+ first = gcCursor;
392
+ gcCursor = (gcCursor + GC_BATCH) % GC_SLOTS;
393
+ }
394
+ int examined = 0, removed = 0, retained = 0, failures = 0;
395
+ try {
396
+ SharedPreferences prefs = ledger(context);
397
+ for (int offset = 0; offset < GC_BATCH; offset++) {
398
+ int slot = (first + offset) % GC_SLOTS;
399
+ examined++;
400
+ if (!reserveSlot(slot)) {
401
+ retained++;
402
+ continue;
403
+ }
404
+ try {
405
+ String key = prefs.getString("gc/slot/" + slot, null);
406
+ if (key == null) continue;
407
+ if (!ticketKey(key) || liveKey(key)) {
408
+ retained++;
409
+ continue;
410
+ }
411
+ String wire = prefs.getString(key, null);
412
+ String state = prefs.getString(key + "/state", null);
413
+ if (wire == null && state == null) continue;
414
+ if (!orphanRecord(key, wire, state) || liveKey(key)) {
415
+ retained++;
416
+ continue;
417
+ }
418
+ // This slot is reserved across all external calls. Neither its immutable victim
419
+ // address nor its index can be reassigned by a reentrant/new arm. Never remove
420
+ // the reusable index slot here: an old editor must only contain victim keys.
421
+ if (prefs.edit().remove(key).remove(key + "/state").commit()) removed++;
422
+ else failures++;
423
+ } catch (RuntimeException failure) {
424
+ failures++;
425
+ } finally {
426
+ releaseSlot(slot);
427
+ }
428
+ }
429
+ } catch (RuntimeException failure) {
430
+ failures++;
431
+ } finally {
432
+ synchronized (GC_LOCK) {
433
+ gcRunning = false;
434
+ }
435
+ marker(
436
+ "GC_RESULT",
437
+ null,
438
+ "examined="
439
+ + examined
440
+ + " removed="
441
+ + removed
442
+ + " retained="
443
+ + retained
444
+ + " failures="
445
+ + failures
446
+ + " unindexed=unknown");
447
+ }
448
+ }
449
+
450
+ static void marker(String event, Ticket ticket, String detail) {
451
+ // android.util.Log only: no EventBus, ReactContext, JS event or headless task.
452
+ Log.i(
453
+ "ForegroundServiceRunLimit",
454
+ event
455
+ + " elapsed="
456
+ + SystemClock.elapsedRealtime()
457
+ + (ticket == null ? "" : " record=" + ticket.wire)
458
+ + " detail="
459
+ + detail);
460
+ }
461
+ }
@@ -662,6 +662,12 @@ class NotificationManager {
662
662
 
663
663
  static ListenableFuture<Void> displayNotification(
664
664
  NotificationModel notificationModel, Bundle triggerBundle) {
665
+ // Reject invalid native/bridge opt-in before dispatching any START or posting a notification.
666
+ try {
667
+ notificationModel.getAndroid().getForegroundServiceBestEffortStopAfterMs();
668
+ } catch (RuntimeException invalidOption) {
669
+ return Futures.immediateFailedFuture(invalidOption);
670
+ }
665
671
  return new ExtendedListenableFuture<>(notificationBundleToBuilder(notificationModel))
666
672
  .continueWith(
667
673
  (taskResult) -> {
@@ -41,6 +41,8 @@ public class NotificationEvent {
41
41
 
42
42
  @KeepForSdk public static final int TYPE_FG_TIMEOUT = 9;
43
43
 
44
+ @KeepForSdk public static final int TYPE_FG_RUN_LIMIT_REACHED = 10;
45
+
44
46
  private final int type;
45
47
  private final Bundle extras;
46
48
  private final NotificationModel notification;
@@ -85,6 +87,10 @@ public class NotificationEvent {
85
87
  eventDetailMap.put("input", input);
86
88
  }
87
89
 
90
+ Bundle runLimit = extras.getBundle("foregroundServiceRunLimit");
91
+ if (runLimit != null)
92
+ eventDetailMap.put("foregroundServiceRunLimit", ObjectUtils.bundleToMap(runLimit));
93
+
88
94
  if (extras.containsKey("startId")) {
89
95
  eventDetailMap.put("startId", extras.getInt("startId"));
90
96
  }
@@ -98,6 +98,35 @@ public class NotificationAndroidModel {
98
98
  return foregroundServiceType;
99
99
  }
100
100
 
101
+ /** Validates bridge input too; never truncate a JS double or silently clamp overflow. */
102
+ @Nullable
103
+ public Long getForegroundServiceBestEffortStopAfterMs() {
104
+ String key = "foregroundServiceBestEffortStopAfterMs";
105
+ if (!mNotificationAndroidBundle.containsKey(key)) return null;
106
+ Object raw = mNotificationAndroidBundle.get(key);
107
+ String error = "notification.android." + key;
108
+ if (!(raw instanceof Number)) throw new IllegalArgumentException(error + " must be a number");
109
+ double value = ((Number) raw).doubleValue();
110
+ if (!Double.isFinite(value) || value != Math.rint(value) || value < 60000 || value > 18000000) {
111
+ throw new IllegalArgumentException(error + " must be an integer in 60000..18000000ms");
112
+ }
113
+ if (!getAsForegroundService() || Build.VERSION.SDK_INT < 35) {
114
+ throw new IllegalArgumentException(error + " requires asForegroundService and API 35+");
115
+ }
116
+ int type = getForegroundServiceType();
117
+ if (type != ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
118
+ && type != ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) {
119
+ throw new IllegalArgumentException(error + " requires single dataSync");
120
+ }
121
+ long duration = ((Number) raw).longValue();
122
+ try {
123
+ Math.addExact(android.os.SystemClock.elapsedRealtime(), duration);
124
+ } catch (ArithmeticException failure) {
125
+ throw new IllegalArgumentException(error + " deadline overflow", failure);
126
+ }
127
+ return duration;
128
+ }
129
+
101
130
  /**
102
131
  * Gets whether the notification is for a foreground service
103
132
  *
@@ -45,6 +45,9 @@ class NotifeeEventSubscriber : EventListener {
45
45
  extras.getString(KEY_DETAIL_INPUT)?.let { input ->
46
46
  eventDetailMap.putString(KEY_DETAIL_INPUT, input)
47
47
  }
48
+ extras.getBundle("foregroundServiceRunLimit")?.let { runLimit ->
49
+ eventDetailMap.putMap("foregroundServiceRunLimit", Arguments.fromBundle(runLimit))
50
+ }
48
51
  if (extras.containsKey("startId")) {
49
52
  eventDetailMap.putInt("startId", extras.getInt("startId"))
50
53
  }