react-native-notify-kit 9.3.0 → 9.4.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 (45) hide show
  1. package/README.md +62 -2
  2. package/android/build.gradle +4 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/baseline-prof.txt +382 -0
  5. package/android/src/main/java/app/notifee/core/ForegroundService.java +269 -80
  6. package/android/src/main/java/app/notifee/core/InitProvider.java +48 -5
  7. package/android/src/main/java/app/notifee/core/Logger.java +5 -0
  8. package/android/src/main/java/app/notifee/core/NotificationManager.java +217 -192
  9. package/android/src/main/java/app/notifee/core/WarmupHelper.java +84 -0
  10. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +17 -1
  11. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +30 -0
  12. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +182 -0
  13. package/dist/NotifeeApiModule.d.ts +1 -0
  14. package/dist/NotifeeApiModule.js +6 -0
  15. package/dist/NotifeeApiModule.js.map +1 -1
  16. package/dist/specs/NativeNotifeeModule.d.ts +1 -0
  17. package/dist/specs/NativeNotifeeModule.js.map +1 -1
  18. package/dist/types/Module.d.ts +19 -0
  19. package/dist/types/NotificationAndroid.d.ts +58 -0
  20. package/dist/types/NotificationAndroid.js +43 -0
  21. package/dist/types/NotificationAndroid.js.map +1 -1
  22. package/dist/utils/validate.d.ts +1 -0
  23. package/dist/utils/validate.js +10 -4
  24. package/dist/utils/validate.js.map +1 -1
  25. package/dist/validators/validateAndroidChannel.js +3 -3
  26. package/dist/validators/validateAndroidChannel.js.map +1 -1
  27. package/dist/validators/validateAndroidNotification.js +36 -12
  28. package/dist/validators/validateAndroidNotification.js.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +5 -1
  32. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +5 -5
  33. package/ios/NotifeeCore/NotifeeCore.m +28 -9
  34. package/ios/NotifeeCore/NotifeeCoreDelegateHolder.m +5 -1
  35. package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +7 -2
  36. package/ios/RNNotifee/NotifeeApiModule.mm +31 -10
  37. package/package.json +1 -1
  38. package/src/NotifeeApiModule.ts +7 -0
  39. package/src/specs/NativeNotifeeModule.ts +1 -0
  40. package/src/types/Module.ts +20 -0
  41. package/src/types/NotificationAndroid.ts +62 -0
  42. package/src/utils/validate.ts +12 -4
  43. package/src/validators/validateAndroidChannel.ts +3 -3
  44. package/src/validators/validateAndroidNotification.ts +43 -10
  45. package/src/version.ts +1 -1
@@ -31,6 +31,7 @@ import android.graphics.Bitmap;
31
31
  import android.net.Uri;
32
32
  import android.os.Build;
33
33
  import android.os.Bundle;
34
+ import android.os.Trace;
34
35
  import android.service.notification.StatusBarNotification;
35
36
  import androidx.annotation.NonNull;
36
37
  import androidx.concurrent.futures.CallbackToFutureAdapter;
@@ -103,198 +104,204 @@ class NotificationManager {
103
104
  */
104
105
  Callable<NotificationCompat.Builder> builderCallable =
105
106
  () -> {
106
- Boolean hasCustomSound = false;
107
- NotificationCompat.Builder builder =
108
- new NotificationCompat.Builder(getApplicationContext(), androidModel.getChannelId());
109
-
110
- // must always keep at top
111
- builder.setExtras(notificationModel.getData());
112
-
113
- builder.setDeleteIntent(
114
- ReceiverService.createIntent(
115
- ReceiverService.DELETE_INTENT,
116
- new String[] {"notification"},
117
- notificationModel.toBundle()));
118
- // Resolve the effective pressAction bundle for the content intent.
119
- // Three cases:
120
- // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
121
- // after app kill): synthesize default { id:'default', launchActivity:'default' }
122
- // so tapping the notification opens the app (defense-in-depth for paths that
123
- // bypass the TS validator).
124
- // 2. pressAction has the opt-out sentinel id: user explicitly passed
125
- // pressAction: null in JS — pass null to createIntent so no launch intent
126
- // is created (non-tappable notification).
127
- // 3. pressAction is a normal bundle: pass through unchanged.
128
- // Resolve the effective pressAction bundle for the content intent.
129
- // Three cases:
130
- // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
131
- // after app kill): synthesize default { id:'default', launchActivity:'default' }
132
- // so tapping the notification opens the app (defense-in-depth for paths that
133
- // bypass the TS validator).
134
- // 2. pressAction has the opt-out sentinel id: user explicitly passed
135
- // pressAction: null in JS — pass null to createIntent so no launch intent
136
- // is created (non-tappable notification).
137
- // 3. pressAction is a normal bundle: pass through unchanged.
138
- //
139
- // pressActionForIntent → used for creating the launch intent (or null for opt-out)
140
- // pressActionForExtras → used in the receiver intent extras (event payload);
141
- // null for cases 1 & 2 to avoid leaking synthesized defaults
142
- // or the sentinel id into the JS event.
143
- Bundle pressActionForIntent = androidModel.getPressAction();
144
- Bundle pressActionForExtras = pressActionForIntent;
145
- if (pressActionForIntent == null) {
146
- // Case 1: absent — synthesize default for launch, null for extras
147
- pressActionForIntent = new Bundle();
148
- pressActionForIntent.putString("id", "default");
149
- pressActionForIntent.putString("launchActivity", "default");
150
- // pressActionForExtras stays null: the original notification didn't have
151
- // pressAction, so the event shouldn't either.
152
- } else if (NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(
153
- pressActionForIntent.getString("id"))) {
154
- // Case 2: explicit opt-out sentinel — no launch intent, no sentinel in extras
155
- pressActionForIntent = null;
156
- pressActionForExtras = null;
157
- }
158
- // Case 3: normal pressAction — both variables point to the original bundle
159
-
160
- int targetSdkVersion =
161
- ContextHolder.getApplicationContext().getApplicationInfo().targetSdkVersion;
162
- if (targetSdkVersion >= Build.VERSION_CODES.S
163
- && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
164
- builder.setContentIntent(
165
- NotificationPendingIntent.createIntent(
166
- notificationModel.getHashCode(),
167
- pressActionForIntent,
168
- TYPE_PRESS,
169
- new String[] {"notification", "pressAction"},
170
- notificationModel.toBundle(),
171
- pressActionForExtras));
172
- } else {
173
- builder.setContentIntent(
107
+ Trace.beginSection("notifee:buildNotification");
108
+ try {
109
+ Boolean hasCustomSound = false;
110
+ NotificationCompat.Builder builder =
111
+ new NotificationCompat.Builder(
112
+ getApplicationContext(), androidModel.getChannelId());
113
+
114
+ // must always keep at top
115
+ builder.setExtras(notificationModel.getData());
116
+
117
+ builder.setDeleteIntent(
174
118
  ReceiverService.createIntent(
175
- ReceiverService.PRESS_INTENT,
176
- new String[] {"notification", "pressAction"},
177
- notificationModel.toBundle(),
178
- pressActionForIntent));
179
- }
119
+ ReceiverService.DELETE_INTENT,
120
+ new String[] {"notification"},
121
+ notificationModel.toBundle()));
122
+ // Resolve the effective pressAction bundle for the content intent.
123
+ // Three cases:
124
+ // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
125
+ // after app kill): synthesize default { id:'default', launchActivity:'default' }
126
+ // so tapping the notification opens the app (defense-in-depth for paths that
127
+ // bypass the TS validator).
128
+ // 2. pressAction has the opt-out sentinel id: user explicitly passed
129
+ // pressAction: null in JS — pass null to createIntent so no launch intent
130
+ // is created (non-tappable notification).
131
+ // 3. pressAction is a normal bundle: pass through unchanged.
132
+ // Resolve the effective pressAction bundle for the content intent.
133
+ // Three cases:
134
+ // 1. pressAction is null (absent from bundle, e.g. trigger rehydrated from Room DB
135
+ // after app kill): synthesize default { id:'default', launchActivity:'default' }
136
+ // so tapping the notification opens the app (defense-in-depth for paths that
137
+ // bypass the TS validator).
138
+ // 2. pressAction has the opt-out sentinel id: user explicitly passed
139
+ // pressAction: null in JS — pass null to createIntent so no launch intent
140
+ // is created (non-tappable notification).
141
+ // 3. pressAction is a normal bundle: pass through unchanged.
142
+ //
143
+ // pressActionForIntent → used for creating the launch intent (or null for opt-out)
144
+ // pressActionForExtras → used in the receiver intent extras (event payload);
145
+ // null for cases 1 & 2 to avoid leaking synthesized defaults
146
+ // or the sentinel id into the JS event.
147
+ Bundle pressActionForIntent = androidModel.getPressAction();
148
+ Bundle pressActionForExtras = pressActionForIntent;
149
+ if (pressActionForIntent == null) {
150
+ // Case 1: absent — synthesize default for launch, null for extras
151
+ pressActionForIntent = new Bundle();
152
+ pressActionForIntent.putString("id", "default");
153
+ pressActionForIntent.putString("launchActivity", "default");
154
+ // pressActionForExtras stays null: the original notification didn't have
155
+ // pressAction, so the event shouldn't either.
156
+ } else if (NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID.equals(
157
+ pressActionForIntent.getString("id"))) {
158
+ // Case 2: explicit opt-out sentinel — no launch intent, no sentinel in extras
159
+ pressActionForIntent = null;
160
+ pressActionForExtras = null;
161
+ }
162
+ // Case 3: normal pressAction — both variables point to the original bundle
163
+
164
+ int targetSdkVersion =
165
+ ContextHolder.getApplicationContext().getApplicationInfo().targetSdkVersion;
166
+ if (targetSdkVersion >= Build.VERSION_CODES.S
167
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
168
+ builder.setContentIntent(
169
+ NotificationPendingIntent.createIntent(
170
+ notificationModel.getHashCode(),
171
+ pressActionForIntent,
172
+ TYPE_PRESS,
173
+ new String[] {"notification", "pressAction"},
174
+ notificationModel.toBundle(),
175
+ pressActionForExtras));
176
+ } else {
177
+ builder.setContentIntent(
178
+ ReceiverService.createIntent(
179
+ ReceiverService.PRESS_INTENT,
180
+ new String[] {"notification", "pressAction"},
181
+ notificationModel.toBundle(),
182
+ pressActionForIntent));
183
+ }
180
184
 
181
- if (notificationModel.getTitle() != null) {
182
- builder.setContentTitle(TextUtils.fromHtml(notificationModel.getTitle()));
183
- }
185
+ if (notificationModel.getTitle() != null) {
186
+ builder.setContentTitle(TextUtils.fromHtml(notificationModel.getTitle()));
187
+ }
184
188
 
185
- if (notificationModel.getSubTitle() != null) {
186
- builder.setSubText(TextUtils.fromHtml(notificationModel.getSubTitle()));
187
- }
189
+ if (notificationModel.getSubTitle() != null) {
190
+ builder.setSubText(TextUtils.fromHtml(notificationModel.getSubTitle()));
191
+ }
188
192
 
189
- if (notificationModel.getBody() != null) {
190
- builder.setContentText(TextUtils.fromHtml(notificationModel.getBody()));
191
- }
193
+ if (notificationModel.getBody() != null) {
194
+ builder.setContentText(TextUtils.fromHtml(notificationModel.getBody()));
195
+ }
192
196
 
193
- if (androidModel.getBadgeIconType() != null) {
194
- builder.setBadgeIconType(androidModel.getBadgeIconType());
195
- }
197
+ if (androidModel.getBadgeIconType() != null) {
198
+ builder.setBadgeIconType(androidModel.getBadgeIconType());
199
+ }
196
200
 
197
- if (androidModel.getCategory() != null) {
198
- builder.setCategory(androidModel.getCategory());
199
- }
201
+ if (androidModel.getCategory() != null) {
202
+ builder.setCategory(androidModel.getCategory());
203
+ }
200
204
 
201
- if (androidModel.getColor() != null) {
202
- builder.setColor(androidModel.getColor());
203
- }
205
+ if (androidModel.getColor() != null) {
206
+ builder.setColor(androidModel.getColor());
207
+ }
204
208
 
205
- builder.setColorized(androidModel.getColorized());
209
+ builder.setColorized(androidModel.getColorized());
206
210
 
207
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
208
- builder.setChronometerCountDown(androidModel.getChronometerCountDown());
209
- }
211
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
212
+ builder.setChronometerCountDown(androidModel.getChronometerCountDown());
213
+ }
210
214
 
211
- if (androidModel.getGroup() != null) {
212
- builder.setGroup(androidModel.getGroup());
213
- }
215
+ if (androidModel.getGroup() != null) {
216
+ builder.setGroup(androidModel.getGroup());
217
+ }
214
218
 
215
- builder.setGroupAlertBehavior(androidModel.getGroupAlertBehaviour());
216
- builder.setGroupSummary(androidModel.getGroupSummary());
219
+ builder.setGroupAlertBehavior(androidModel.getGroupAlertBehaviour());
220
+ builder.setGroupSummary(androidModel.getGroupSummary());
217
221
 
218
- if (androidModel.getInputHistory() != null) {
219
- builder.setRemoteInputHistory(androidModel.getInputHistory());
220
- }
222
+ if (androidModel.getInputHistory() != null) {
223
+ builder.setRemoteInputHistory(androidModel.getInputHistory());
224
+ }
221
225
 
222
- if (androidModel.getLights() != null) {
223
- ArrayList<Integer> lights = androidModel.getLights();
224
- builder.setLights(lights.get(0), lights.get(1), lights.get(2));
225
- }
226
+ if (androidModel.getLights() != null) {
227
+ ArrayList<Integer> lights = androidModel.getLights();
228
+ builder.setLights(lights.get(0), lights.get(1), lights.get(2));
229
+ }
226
230
 
227
- builder.setLocalOnly(androidModel.getLocalOnly());
231
+ builder.setLocalOnly(androidModel.getLocalOnly());
228
232
 
229
- if (androidModel.getNumber() != null) {
230
- builder.setNumber(androidModel.getNumber());
231
- }
233
+ if (androidModel.getNumber() != null) {
234
+ builder.setNumber(androidModel.getNumber());
235
+ }
232
236
 
233
- if (androidModel.getSound() != null) {
234
- Uri soundUri = ResourceUtils.getSoundUri(androidModel.getSound());
235
- if (soundUri != null) {
236
- hasCustomSound = true;
237
- builder.setSound(soundUri);
238
- } else {
239
- Logger.w(
240
- TAG,
241
- "Unable to retrieve sound for notification, sound was specified as: "
242
- + androidModel.getSound());
237
+ if (androidModel.getSound() != null) {
238
+ Uri soundUri = ResourceUtils.getSoundUri(androidModel.getSound());
239
+ if (soundUri != null) {
240
+ hasCustomSound = true;
241
+ builder.setSound(soundUri);
242
+ } else {
243
+ Logger.w(
244
+ TAG,
245
+ "Unable to retrieve sound for notification, sound was specified as: "
246
+ + androidModel.getSound());
247
+ }
243
248
  }
244
- }
245
249
 
246
- builder.setDefaults(androidModel.getDefaults(hasCustomSound));
247
- builder.setOngoing(androidModel.getOngoing());
248
- builder.setOnlyAlertOnce(androidModel.getOnlyAlertOnce());
249
- builder.setPriority(androidModel.getPriority());
250
+ builder.setDefaults(androidModel.getDefaults(hasCustomSound));
251
+ builder.setOngoing(androidModel.getOngoing());
252
+ builder.setOnlyAlertOnce(androidModel.getOnlyAlertOnce());
253
+ builder.setPriority(androidModel.getPriority());
250
254
 
251
- NotificationAndroidModel.AndroidProgress progress = androidModel.getProgress();
252
- if (progress != null) {
253
- builder.setProgress(
254
- progress.getMax(), progress.getCurrent(), progress.getIndeterminate());
255
- }
255
+ NotificationAndroidModel.AndroidProgress progress = androidModel.getProgress();
256
+ if (progress != null) {
257
+ builder.setProgress(
258
+ progress.getMax(), progress.getCurrent(), progress.getIndeterminate());
259
+ }
256
260
 
257
- if (androidModel.getShortcutId() != null) {
258
- builder.setShortcutId(androidModel.getShortcutId());
259
- }
261
+ if (androidModel.getShortcutId() != null) {
262
+ builder.setShortcutId(androidModel.getShortcutId());
263
+ }
260
264
 
261
- builder.setShowWhen(androidModel.getShowTimestamp());
265
+ builder.setShowWhen(androidModel.getShowTimestamp());
262
266
 
263
- Integer smallIconId = androidModel.getSmallIcon();
264
- if (smallIconId != null) {
265
- Integer smallIconLevel = androidModel.getSmallIconLevel();
266
- if (smallIconLevel != null) {
267
- builder.setSmallIcon(smallIconId, smallIconLevel);
268
- } else {
269
- builder.setSmallIcon(smallIconId);
267
+ Integer smallIconId = androidModel.getSmallIcon();
268
+ if (smallIconId != null) {
269
+ Integer smallIconLevel = androidModel.getSmallIconLevel();
270
+ if (smallIconLevel != null) {
271
+ builder.setSmallIcon(smallIconId, smallIconLevel);
272
+ } else {
273
+ builder.setSmallIcon(smallIconId);
274
+ }
270
275
  }
271
- }
272
276
 
273
- if (androidModel.getSortKey() != null) {
274
- builder.setSortKey(androidModel.getSortKey());
275
- }
277
+ if (androidModel.getSortKey() != null) {
278
+ builder.setSortKey(androidModel.getSortKey());
279
+ }
276
280
 
277
- if (androidModel.getTicker() != null) {
278
- builder.setTicker(androidModel.getTicker());
279
- }
281
+ if (androidModel.getTicker() != null) {
282
+ builder.setTicker(androidModel.getTicker());
283
+ }
280
284
 
281
- if (androidModel.getTimeoutAfter() != null) {
282
- builder.setTimeoutAfter(androidModel.getTimeoutAfter());
283
- }
285
+ if (androidModel.getTimeoutAfter() != null) {
286
+ builder.setTimeoutAfter(androidModel.getTimeoutAfter());
287
+ }
284
288
 
285
- builder.setUsesChronometer(androidModel.getShowChronometer());
289
+ builder.setUsesChronometer(androidModel.getShowChronometer());
286
290
 
287
- long[] vibrationPattern = androidModel.getVibrationPattern();
288
- if (vibrationPattern.length > 0) builder.setVibrate(vibrationPattern);
291
+ long[] vibrationPattern = androidModel.getVibrationPattern();
292
+ if (vibrationPattern.length > 0) builder.setVibrate(vibrationPattern);
289
293
 
290
- builder.setVisibility(androidModel.getVisibility());
294
+ builder.setVisibility(androidModel.getVisibility());
291
295
 
292
- long timestamp = androidModel.getTimestamp();
293
- if (timestamp > -1) builder.setWhen(timestamp);
296
+ long timestamp = androidModel.getTimestamp();
297
+ if (timestamp > -1) builder.setWhen(timestamp);
294
298
 
295
- builder.setAutoCancel(androidModel.getAutoCancel());
299
+ builder.setAutoCancel(androidModel.getAutoCancel());
296
300
 
297
- return builder;
301
+ return builder;
302
+ } finally {
303
+ Trace.endSection();
304
+ }
298
305
  };
299
306
 
300
307
  /*
@@ -576,7 +583,8 @@ class NotificationManager {
576
583
  Logger.e(
577
584
  TAG,
578
585
  "cancelAllNotificationsWithIds -> Failed to parse id as integer "
579
- + id);
586
+ + id,
587
+ e);
580
588
  }
581
589
 
582
590
  if (integerId != null) {
@@ -619,47 +627,64 @@ class NotificationManager {
619
627
  return new ExtendedListenableFuture<>(notificationBundleToBuilder(notificationModel))
620
628
  .continueWith(
621
629
  (taskResult) -> {
622
- NotificationCompat.Builder builder = taskResult;
630
+ Trace.beginSection("notifee:displayNotification");
631
+ try {
632
+ NotificationCompat.Builder builder = taskResult;
633
+
634
+ // Add the following extras for `getDisplayedNotifications()`
635
+ Bundle extrasBundle = new Bundle();
636
+ extrasBundle.putBundle(EXTRA_NOTIFEE_NOTIFICATION, notificationModel.toBundle());
637
+ if (triggerBundle != null) {
638
+ extrasBundle.putBundle(EXTRA_NOTIFEE_TRIGGER, triggerBundle);
639
+ }
640
+ builder.addExtras(extrasBundle);
623
641
 
624
- // Add the following extras for `getDisplayedNotifications()`
625
- Bundle extrasBundle = new Bundle();
626
- extrasBundle.putBundle(EXTRA_NOTIFEE_NOTIFICATION, notificationModel.toBundle());
627
- if (triggerBundle != null) {
628
- extrasBundle.putBundle(EXTRA_NOTIFEE_TRIGGER, triggerBundle);
629
- }
630
- builder.addExtras(extrasBundle);
642
+ NotificationAndroidModel androidBundle = notificationModel.getAndroid();
631
643
 
632
- // build notification
633
- Notification notification = Objects.requireNonNull(builder).build();
644
+ // Set foreground service behavior before building (only for FGS notifications).
645
+ // IMMEDIATE eliminates the 10-second display delay on Android 12+.
646
+ if (androidBundle.getAsForegroundService()) {
647
+ builder.setForegroundServiceBehavior(
648
+ androidBundle.getForegroundServiceBehavior());
649
+ }
634
650
 
635
- int hashCode = notificationModel.getHashCode();
651
+ // build notification
652
+ Notification notification = Objects.requireNonNull(builder).build();
636
653
 
637
- NotificationAndroidModel androidBundle = notificationModel.getAndroid();
638
- if (androidBundle.getLoopSound()) {
639
- notification.flags |= Notification.FLAG_INSISTENT;
640
- }
654
+ int hashCode = notificationModel.getHashCode();
655
+ if (androidBundle.getLoopSound()) {
656
+ notification.flags |= Notification.FLAG_INSISTENT;
657
+ }
641
658
 
642
- if (androidBundle.getFlags() != null && androidBundle.getFlags().length > 0) {
643
- for (int flag : androidBundle.getFlags()) {
644
- notification.flags |= flag;
659
+ if (androidBundle.getFlags() != null && androidBundle.getFlags().length > 0) {
660
+ for (int flag : androidBundle.getFlags()) {
661
+ notification.flags |= flag;
662
+ }
645
663
  }
646
- }
647
664
 
648
- if (androidBundle.getLightUpScreen()) {
649
- PowerManagerUtils.lightUpScreenIfNeeded(ContextHolder.getApplicationContext());
650
- }
665
+ if (androidBundle.getLightUpScreen()) {
666
+ PowerManagerUtils.lightUpScreenIfNeeded(ContextHolder.getApplicationContext());
667
+ }
651
668
 
652
- if (androidBundle.getAsForegroundService()) {
653
- ForegroundService.start(hashCode, notification, notificationModel.toBundle());
654
- } else {
655
- NotificationManagerCompat.from(getApplicationContext())
656
- .notify(androidBundle.getTag(), hashCode, notification);
657
- }
669
+ if (androidBundle.getAsForegroundService()) {
670
+ Trace.beginSection("notifee:startForegroundService");
671
+ try {
672
+ ForegroundService.start(hashCode, notification, notificationModel.toBundle());
673
+ } finally {
674
+ Trace.endSection();
675
+ }
676
+ } else {
677
+ NotificationManagerCompat.from(getApplicationContext())
678
+ .notify(androidBundle.getTag(), hashCode, notification);
679
+ }
658
680
 
659
- EventBus.post(
660
- new NotificationEvent(NotificationEvent.TYPE_DELIVERED, notificationModel));
681
+ EventBus.post(
682
+ new NotificationEvent(NotificationEvent.TYPE_DELIVERED, notificationModel));
661
683
 
662
- return Futures.immediateFuture(null);
684
+ return Futures.immediateFuture(null);
685
+ } finally {
686
+ Trace.endSection();
687
+ }
663
688
  },
664
689
  CACHED_THREAD_POOL);
665
690
  }
@@ -0,0 +1,84 @@
1
+ package app.notifee.core;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import android.content.Context;
21
+ import android.os.Trace;
22
+ import androidx.core.app.NotificationManagerCompat;
23
+
24
+ /**
25
+ * Encapsulates the foreground-service warmup logic so it can be called from both {@link
26
+ * InitProvider} (automatic, at app startup) and the JS bridge ({@code prewarmForegroundService()},
27
+ * on-demand).
28
+ *
29
+ * <p>This class runs <b>synchronously</b> on the caller's thread. Callers are responsible for
30
+ * choosing the appropriate thread/executor.
31
+ *
32
+ * <p>Safe to call multiple times (idempotent). After the first call, class loading is a no-op from
33
+ * ART's perspective and the Binder proxy call is cheap.
34
+ */
35
+ @KeepForSdk
36
+ public final class WarmupHelper {
37
+
38
+ private static final String TAG = "WarmupHelper";
39
+
40
+ static final String[] WARMUP_CLASSES = {
41
+ "app.notifee.core.ForegroundService",
42
+ "app.notifee.core.NotificationManager",
43
+ "app.notifee.core.model.NotificationModel",
44
+ "app.notifee.core.model.NotificationAndroidModel",
45
+ "app.notifee.core.model.ChannelModel",
46
+ "androidx.core.app.NotificationCompat$Builder",
47
+ "androidx.core.app.NotificationManagerCompat",
48
+ };
49
+
50
+ private WarmupHelper() {}
51
+
52
+ /**
53
+ * Pre-loads critical foreground-service classes and warms the INotificationManager Binder proxy.
54
+ * Runs synchronously on the caller's thread.
55
+ *
56
+ * @param context Application context used for class loading and Binder warmup.
57
+ */
58
+ @KeepForSdk
59
+ public static void runWarmup(Context context) {
60
+ Trace.beginSection("notifee:warmup");
61
+ try {
62
+ ClassLoader classLoader = context.getClassLoader();
63
+
64
+ // Pre-load critical foreground service classes to move ART class loading/verification
65
+ // cost from the first displayNotification() call to whenever the caller chooses.
66
+ for (String className : WARMUP_CLASSES) {
67
+ try {
68
+ Class.forName(className, true, classLoader);
69
+ } catch (ClassNotFoundException e) {
70
+ Logger.d(TAG, "Warmup class not found: " + className);
71
+ }
72
+ }
73
+
74
+ // Pre-warm INotificationManager Binder proxy by touching NotificationManagerCompat.
75
+ try {
76
+ NotificationManagerCompat.from(context).getNotificationChannels();
77
+ } catch (Exception e) {
78
+ Logger.d(TAG, "Warmup Binder pre-warm failed: " + e.getMessage());
79
+ }
80
+ } finally {
81
+ Trace.endSection();
82
+ }
83
+ }
84
+ }
@@ -74,6 +74,11 @@ public class NotificationAndroidModel {
74
74
  Objects.requireNonNull(
75
75
  mNotificationAndroidBundle.getParcelableArrayList("foregroundServiceTypes"));
76
76
 
77
+ if (foregroundServiceTypesArrayList.isEmpty()) {
78
+ Logger.w(TAG, "foregroundServiceTypes is empty; treating as absent");
79
+ return ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST;
80
+ }
81
+
77
82
  int foregroundServiceType = 0;
78
83
  for (int i = 0; i < foregroundServiceTypesArrayList.size(); i++) {
79
84
  foregroundServiceType |= ObjectUtils.getInt(foregroundServiceTypesArrayList.get(i));
@@ -97,6 +102,17 @@ public class NotificationAndroidModel {
97
102
  return mNotificationAndroidBundle.getBoolean("asForegroundService", false);
98
103
  }
99
104
 
105
+ /**
106
+ * Gets the foreground service behavior for the notification. Controls whether the foreground
107
+ * service notification is shown immediately or deferred on Android 12+.
108
+ *
109
+ * @return int matching a NotificationCompat.FOREGROUND_SERVICE_* constant
110
+ */
111
+ public int getForegroundServiceBehavior() {
112
+ return mNotificationAndroidBundle.getInt(
113
+ "foregroundServiceBehavior", NotificationCompat.FOREGROUND_SERVICE_DEFAULT);
114
+ }
115
+
100
116
  /**
101
117
  * Gets if the notification should light up the screen when displayed
102
118
  *
@@ -328,7 +344,7 @@ public class NotificationAndroidModel {
328
344
 
329
345
  return lights;
330
346
  } catch (Exception e) {
331
- Logger.e(TAG, "getLights -> Failed to parse lights");
347
+ Logger.e(TAG, "getLights -> Failed to parse lights", e);
332
348
  return null;
333
349
  }
334
350
  }
@@ -317,6 +317,36 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
317
317
  }
318
318
  }
319
319
 
320
+ override fun prewarmForegroundService(promise: Promise) {
321
+ val context = reactApplicationContext.applicationContext ?: run {
322
+ promise.reject("ERR_CONTEXT", "ReactApplicationContext is null")
323
+ return
324
+ }
325
+ val executor = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
326
+ Thread(r, "notifee-prewarm").apply {
327
+ isDaemon = true
328
+ priority = Thread.MIN_PRIORITY
329
+ }
330
+ }
331
+ executor.submit {
332
+ android.os.Trace.beginSection("notifee:prewarm")
333
+ try {
334
+ app.notifee.core.WarmupHelper.runWarmup(context)
335
+ promise.resolve(null)
336
+ } catch (t: Throwable) {
337
+ // Best-effort semantics: warmup failures don't reject the promise.
338
+ // WarmupHelper logs and swallows internal errors; if something
339
+ // unexpectedly escapes, log it and still resolve so the JS-side
340
+ // await does not hang.
341
+ Logger.e("NotifeeApiModule", "prewarmForegroundService unexpectedly threw", t)
342
+ promise.resolve(null)
343
+ } finally {
344
+ android.os.Trace.endSection()
345
+ }
346
+ }
347
+ executor.shutdown()
348
+ }
349
+
320
350
  override fun hideNotificationDrawer() {
321
351
  NotifeeReactUtils.hideNotificationDrawer()
322
352
  }