react-native-notify-kit 10.4.3 → 10.4.5

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.
package/README.md CHANGED
@@ -433,7 +433,8 @@ This fork is a complete migration to React Native's **New Architecture**:
433
433
  - **Android bridge rewritten in Kotlin** (original was Java)
434
434
  - **iOS bridge uses Objective-C++** with `NativeNotifeeModuleSpecJSI` TurboModule conformance
435
435
  - **Minimum React Native >=0.73**, development target **0.85.3**
436
- - **Toolchain**: Yarn 4, Node 22+, Java 17, library Android module compileSdk/targetSdk 35
436
+ - **Toolchain**: Yarn 4, Node 22+, JDK 17+ for Android builds, library Android module compileSdk/targetSdk 35
437
+ - **Android JDK baseline**: JDK 17 and JDK 21 are the validated Android build baselines; newer JDKs are not blocked by NotifyKit but depend on the consumer Android toolchain
437
438
  - **Single Android module** — the original Notifee shipped a pre-compiled AAR bundled inside the npm tarball under a frozen Maven coordinate; this fork compiles the core from source as part of the React Native bridge module on every consumer build. Eliminates the `FAIL_ON_PROJECT_REPOS` issue on RN 0.74+ and the Gradle cache staleness bug that could serve outdated bytecode after `yarn upgrade`.
438
439
  - **Expo CNG development builds** — iOS FCM Mode NSE automation and Android foreground service manifest configuration are available through the config plugin. Android Expo FCM smoke validation uses RNFirebase data-only messages plus `notifee.handleFcmMessage`.
439
440
  - **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
@@ -498,7 +499,7 @@ This fork fixes the following bugs that were never resolved in the original Noti
498
499
 
499
500
  <!-- markdownlint-disable-line MD028 -->
500
501
 
501
- > **Note for apps requiring guaranteed exact alarms (alarm clocks, timers, calendars):**
502
+ > **Note for policy-eligible apps requiring exact alarm APIs (alarm clocks, timers, calendars):**
502
503
  > Add `<uses-permission android:name="android.permission.USE_EXACT_ALARM" />` to your app's
503
504
  > `AndroidManifest.xml`. This permission is auto-granted and not revocable, but Google Play
504
505
  > restricts its use to apps whose core function requires exact timing.
@@ -658,6 +659,13 @@ The original Notifee used WorkManager by default, which is battery-friendly but
658
659
  for time-sensitive notifications — Android may defer or drop WorkManager tasks based on
659
660
  battery optimization, Doze mode, and OEM power management.
660
661
 
662
+ This path also handles normal reboot recovery. Android clears `AlarmManager` registrations
663
+ during reboot, but NotifyKit persists local trigger notifications and re-arms them after boot.
664
+ Future one-shot `TriggerType.TIMESTAMP` triggers are supported by this recovery path; a trigger
665
+ does not need to be recurring or daily only to survive reboot, and push notifications are not
666
+ required for local scheduled trigger recovery. If exact-alarm access is missing, NotifyKit falls
667
+ back to an inexact alarm so the trigger is retained, but exact fire timing is not guaranteed.
668
+
661
669
  If you need battery-friendly scheduling where exact timing is not critical (e.g., daily digest
662
670
  notifications), you can opt out:
663
671
 
@@ -718,12 +726,14 @@ await notifee.createTriggerNotification(
718
726
  );
719
727
  ```
720
728
 
721
- **Required permissions on Android 12+.** `SET_EXACT`, `SET_EXACT_AND_ALLOW_WHILE_IDLE`, and
722
- `SET_ALARM_CLOCK` all require the `SCHEDULE_EXACT_ALARM` or `USE_EXACT_ALARM` permission.
723
- If the permission is not granted, Notifee falls back to `setAndAllowWhileIdle` (inexact)
724
- instead of crashing see the `SecurityException` handling in `NotifeeAlarmManager`.
725
- For use cases that must be exact on first install, declare `USE_EXACT_ALARM` in your manifest
726
- and consider prompting the user with `openAlarmPermissionSettings()`.
729
+ **Exact-alarm permission on Android 12+.** `SET_EXACT`, `SET_EXACT_AND_ALLOW_WHILE_IDLE`,
730
+ and `SET_ALARM_CLOCK` all require exact-alarm access through `SCHEDULE_EXACT_ALARM` or
731
+ `USE_EXACT_ALARM`. This affects exact fire timing, not trigger persistence. If permission is
732
+ not granted, Notifee falls back to `setAndAllowWhileIdle` (inexact) instead of crashing or
733
+ dropping the trigger. Apps that require exact timing should check
734
+ `getNotificationSettings().android.alarm`, explain the permission, and guide users with
735
+ `openAlarmPermissionSettings()`. `SET_ALARM_CLOCK` can be considered for alarm-clock or
736
+ timer-like use cases within Android and Google Play policy constraints.
727
737
 
728
738
  ### Timers: foreground service or `SET_ALARM_CLOCK`?
729
739
 
@@ -809,6 +819,33 @@ Default is `true` (backward compatible — Notifee handles everything, same as o
809
819
 
810
820
  ### Troubleshooting
811
821
 
822
+ #### Scheduled Android trigger notifications do not fire after reboot
823
+
824
+ Android removes `AlarmManager` registrations during reboot. NotifyKit persists local trigger
825
+ notifications and re-arms them through `app.notifee.core.RebootBroadcastReceiver` after a
826
+ normal reboot, with a cold-start `BOOT_COUNT` recovery path for devices that delay or suppress
827
+ `BOOT_COMPLETED`. If triggers do not return after reboot, check the generated app rather than
828
+ only the source manifest:
829
+
830
+ - Verify the final merged manifest contains `android.permission.RECEIVE_BOOT_COMPLETED`,
831
+ `android.permission.SCHEDULE_EXACT_ALARM`, `app.notifee.core.RebootBroadcastReceiver` with
832
+ `android.intent.action.BOOT_COMPLETED`, `app.notifee.core.NotificationAlarmReceiver`, and
833
+ `io.invertase.notifee.NotifeeInitProvider`.
834
+ - For Expo CNG/prebuild apps, these entries normally arrive from the NotifyKit library manifest
835
+ through manifest merge. A custom boot-receiver config plugin is usually unnecessary and can
836
+ break recovery if it replaces the receiver declaration or intent filters.
837
+ - Use package-specific logcat output. Do not rely only on `adb logcat -s NOTIFEE`; reboot
838
+ recovery logs use native tags such as `RebootReceiver`, `NotifeeAlarmManager`, `InitProvider`,
839
+ and `AlarmPermissionReceiver`, while Android broadcast logs include your real application ID.
840
+ - Compare `adb shell dumpsys alarm` before and after reboot, filtered by your real package name,
841
+ to confirm the pending alarm disappears during reboot and is re-armed afterward.
842
+ - Check exact-alarm permission if timing is late. Without permission, NotifyKit falls back to an
843
+ inexact alarm: the trigger is retained, but exact fire timing is not guaranteed.
844
+ - Check OEM autostart and background restrictions, especially on Xiaomi/Redmi, Oppo/Realme,
845
+ Huawei/Honor, Vivo/iQOO, and Samsung devices.
846
+ - Validate with 1-5 future one-shot `TriggerType.TIMESTAMP` triggers before scaling up to large
847
+ batches such as 50 active triggers.
848
+
812
849
  #### Small icon not showing in Android release builds (falls back to launcher icon)
813
850
 
814
851
  From 10.1.0, when the resource ID for `android.smallIcon` cannot be resolved at runtime, the library logs a warning and falls back to your app's launcher icon instead of failing the notification display. If you see your launcher icon in a notification where you expected a custom small icon, filter logcat for the `NOTIFEE` tag to find the resolution failure.
@@ -25,16 +25,30 @@ plugins {
25
25
 
26
26
  ext {
27
27
  jvmVersion = Jvm.current().javaVersion.majorVersion
28
- if (jvmVersion != "8" && jvmVersion != "11" && jvmVersion != "17" && jvmVersion != "21") {
28
+ jvmMajorVersion = jvmVersion.toInteger()
29
+ if (jvmMajorVersion < 17) {
29
30
  println "\n\n\n"
30
31
  println "**************************************************************************************************************"
31
32
  println "\n\n\n"
32
- println "ERROR: Notifee builds with JVM LTS and current releases (currently major version 11, 17, or 21)."
33
+ println "ERROR: Notifee requires JDK 17 or newer to build on Android."
34
+ println " Obsolete Java versions such as 8 and 11 are not supported."
33
35
  println " Incompatible major version detected: '" + jvmVersion + "'"
34
36
  println "\n\n\n"
35
37
  println "**************************************************************************************************************"
36
38
  println "\n\n\n"
37
- System.exit(1)
39
+ throw new GradleException("Notifee requires JDK 17 or newer to build on Android. Detected Java major version '" + jvmVersion + "'.")
40
+ }
41
+ if (jvmMajorVersion != 17 && jvmMajorVersion != 21) {
42
+ println "\n\n\n"
43
+ println "**************************************************************************************************************"
44
+ println "\n\n\n"
45
+ println "WARNING: Notifee Android builds are validated with JDK 17 and JDK 21."
46
+ println " Detected Java major version: '" + jvmVersion + "'."
47
+ println " The build will continue, but compatibility depends on the consumer Gradle, AGP, Kotlin, and React Native toolchain."
48
+ println " Use JDK 17 or JDK 21 for a stable build baseline."
49
+ println "\n\n\n"
50
+ println "**************************************************************************************************************"
51
+ println "\n\n\n"
38
52
  }
39
53
  }
40
54
 
@@ -451,23 +451,27 @@ class NotificationManager {
451
451
  PendingIntent pendingIntent = null;
452
452
  int targetSdkVersion =
453
453
  ContextHolder.getApplicationContext().getApplicationInfo().targetSdkVersion;
454
+ NotificationAndroidPressActionModel pressAction = actionBundle.getPressAction();
455
+ boolean shouldCreateLaunchActivityIntent =
456
+ NotificationPendingIntent.shouldCreateLaunchActivityIntent(pressAction);
454
457
  if (targetSdkVersion >= Build.VERSION_CODES.S
455
- && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
458
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
459
+ && shouldCreateLaunchActivityIntent) {
456
460
  pendingIntent =
457
461
  NotificationPendingIntent.createIntent(
458
462
  notificationModel.getHashCode(),
459
- actionBundle.getPressAction().toBundle(),
463
+ pressAction.toBundle(),
460
464
  TYPE_ACTION_PRESS,
461
465
  new String[] {"notification", "pressAction"},
462
466
  notificationModel.toBundle(),
463
- actionBundle.getPressAction().toBundle());
467
+ pressAction.toBundle());
464
468
  } else {
465
469
  pendingIntent =
466
470
  ReceiverService.createIntent(
467
471
  ACTION_PRESS_INTENT,
468
472
  new String[] {"notification", "pressAction"},
469
473
  notificationModel.toBundle(),
470
- actionBundle.getPressAction().toBundle());
474
+ pressAction.toBundle());
471
475
  }
472
476
 
473
477
  String icon = actionBundle.getIcon();
@@ -98,9 +98,21 @@ class HeadlessTask {
98
98
 
99
99
  if (!mIsReactContextInitialized.get()) {
100
100
  createReactContextAndScheduleTask(context)
101
- } else {
102
- invokeStartTask(getReactContext(context)!!, taskConfig)
101
+ return
102
+ }
103
+
104
+ val reactContext = getReactContext(context)
105
+ if (reactContext == null) {
106
+ Log.w(HEADLESS_TASK_NAME, "ReactContext is stale, reinitializing ReactHost")
107
+ mIsReactContextInitialized.set(false)
108
+ mWillDrainTaskQueue.set(false)
109
+ mIsInitializingReactContext.set(false)
110
+ mIsHeadlessJsTaskListenerRegistered.set(false)
111
+ createReactContextAndScheduleTask(context, reactContext)
112
+ return
103
113
  }
114
+
115
+ invokeStartTask(reactContext, taskConfig)
104
116
  }
105
117
 
106
118
  @Synchronized
@@ -139,11 +151,13 @@ class HeadlessTask {
139
151
  }
140
152
  }
141
153
 
142
- private fun createReactContextAndScheduleTask(context: Context) {
143
- val reactContext = getReactContext(context)
154
+ private fun createReactContextAndScheduleTask(
155
+ context: Context,
156
+ reactContext: ReactContext? = getReactContext(context),
157
+ ) {
144
158
  if (reactContext != null && !mIsInitializingReactContext.get()) {
145
159
  mIsReactContextInitialized.set(true)
146
- drainTaskQueue(reactContext)
160
+ drainTaskQueue(context, reactContext)
147
161
  return
148
162
  }
149
163
  if (mIsInitializingReactContext.compareAndSet(false, true)) {
@@ -152,7 +166,7 @@ class HeadlessTask {
152
166
  val callback = object : ReactInstanceEventListener {
153
167
  override fun onReactContextInitialized(reactCtx: ReactContext) {
154
168
  mIsReactContextInitialized.set(true)
155
- drainTaskQueue(reactCtx)
169
+ drainTaskQueue(context, reactCtx)
156
170
  try {
157
171
  val removeMethod = reactHost!!.javaClass.getMethod(
158
172
  "removeReactInstanceEventListener",
@@ -178,10 +192,12 @@ class HeadlessTask {
178
192
  }
179
193
  }
180
194
 
181
- private fun drainTaskQueue(reactContext: ReactContext) {
195
+ private fun drainTaskQueue(context: Context, reactContext: ReactContext) {
182
196
  if (mWillDrainTaskQueue.compareAndSet(false, true)) {
183
197
  Handler(Looper.getMainLooper()).postDelayed(
184
198
  {
199
+ if (getReactContext(context) !== reactContext) return@postDelayed
200
+
185
201
  synchronized(mTaskQueue) {
186
202
  for (taskConfig in mTaskQueue) {
187
203
  invokeStartTask(reactContext, taskConfig)
@@ -0,0 +1,305 @@
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
+ import static app.notifee.core.NotificationPendingIntent.EVENT_TYPE_INTENT_KEY;
20
+ import static app.notifee.core.event.NotificationEvent.TYPE_ACTION_PRESS;
21
+ import static org.junit.Assert.assertEquals;
22
+ import static org.junit.Assert.assertNotNull;
23
+ import static org.junit.Assert.assertTrue;
24
+
25
+ import android.Manifest;
26
+ import android.app.Activity;
27
+ import android.app.Notification;
28
+ import android.app.NotificationChannel;
29
+ import android.app.PendingIntent;
30
+ import android.content.ComponentName;
31
+ import android.content.Context;
32
+ import android.content.Intent;
33
+ import android.content.pm.ActivityInfo;
34
+ import android.content.pm.ResolveInfo;
35
+ import android.os.Build;
36
+ import android.os.Bundle;
37
+ import app.notifee.core.event.MainComponentEvent;
38
+ import app.notifee.core.model.NotificationModel;
39
+ import com.google.common.util.concurrent.ListenableFuture;
40
+ import java.util.ArrayList;
41
+ import java.util.concurrent.TimeUnit;
42
+ import org.junit.After;
43
+ import org.junit.Before;
44
+ import org.junit.Test;
45
+ import org.junit.runner.RunWith;
46
+ import org.robolectric.RobolectricTestRunner;
47
+ import org.robolectric.RuntimeEnvironment;
48
+ import org.robolectric.Shadows;
49
+ import org.robolectric.annotation.Config;
50
+ import org.robolectric.shadows.ShadowNotificationManager;
51
+ import org.robolectric.shadows.ShadowPackageManager;
52
+ import org.robolectric.shadows.ShadowPendingIntent;
53
+
54
+ @RunWith(RobolectricTestRunner.class)
55
+ @Config(sdk = 34)
56
+ public class NotificationManagerActionRoutingTest {
57
+ private static final String CHANNEL_ID = "action-routing-test-channel";
58
+
59
+ private Context context;
60
+
61
+ @Before
62
+ public void setUp() {
63
+ context = RuntimeEnvironment.getApplication();
64
+ ContextHolder.setApplicationContext(context);
65
+ setTargetSdk(Build.VERSION_CODES.S);
66
+ ShadowPendingIntent.reset();
67
+ EventBus.removeStickEvent(MainComponentEvent.class);
68
+
69
+ Shadows.shadowOf(RuntimeEnvironment.getApplication())
70
+ .grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
71
+ registerDefaultLaunchActivity();
72
+ createChannel();
73
+
74
+ android.app.NotificationManager notificationManager =
75
+ (android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
76
+ notificationManager.cancelAll();
77
+ }
78
+
79
+ @After
80
+ public void tearDown() {
81
+ EventBus.removeStickEvent(MainComponentEvent.class);
82
+ ShadowPendingIntent.reset();
83
+ }
84
+
85
+ @Test
86
+ public void displayNotification_android12PlusNoLaunchAction_routesToReceiverService()
87
+ throws Exception {
88
+ ShadowPendingIntent shadowPendingIntent =
89
+ actionPendingIntent(
90
+ displayNotification(
91
+ buildNotificationModel(
92
+ "android12-no-launch", buildPressAction("decline", null, null))));
93
+
94
+ assertReceiverServiceActionIntent(shadowPendingIntent, "decline", null, null);
95
+ }
96
+
97
+ @Test
98
+ public void displayNotification_android12PlusLaunchActivityAction_routesToLaunchActivity()
99
+ throws Exception {
100
+ ShadowPendingIntent shadowPendingIntent =
101
+ actionPendingIntent(
102
+ displayNotification(
103
+ buildNotificationModel(
104
+ "android12-launch-activity",
105
+ buildPressAction("answer", ExplicitLaunchActivity.class.getName(), null))));
106
+
107
+ assertLaunchActivityActionIntent(
108
+ shadowPendingIntent,
109
+ ExplicitLaunchActivity.class,
110
+ "answer",
111
+ ExplicitLaunchActivity.class.getName(),
112
+ null);
113
+ }
114
+
115
+ @Test
116
+ public void displayNotification_android12PlusMainComponentAction_routesToLaunchActivity()
117
+ throws Exception {
118
+ ShadowPendingIntent shadowPendingIntent =
119
+ actionPendingIntent(
120
+ displayNotification(
121
+ buildNotificationModel(
122
+ "android12-main-component",
123
+ buildPressAction("open-main", null, "CustomMainComponent"))));
124
+
125
+ assertLaunchActivityActionIntent(
126
+ shadowPendingIntent, DefaultLaunchActivity.class, "open-main", null, "CustomMainComponent");
127
+ }
128
+
129
+ @Test
130
+ public void displayNotification_android12PlusDefaultActionId_routesToLaunchActivity()
131
+ throws Exception {
132
+ ShadowPendingIntent shadowPendingIntent =
133
+ actionPendingIntent(
134
+ displayNotification(
135
+ buildNotificationModel(
136
+ "android12-default-action", buildPressAction("default", null, null))));
137
+
138
+ assertLaunchActivityActionIntent(
139
+ shadowPendingIntent, DefaultLaunchActivity.class, "default", null, null);
140
+ }
141
+
142
+ @Test
143
+ @Config(sdk = 30)
144
+ public void displayNotification_preAndroid12LaunchActivityAction_keepsReceiverServiceRoute()
145
+ throws Exception {
146
+ setTargetSdk(Build.VERSION_CODES.S);
147
+
148
+ ShadowPendingIntent shadowPendingIntent =
149
+ actionPendingIntent(
150
+ displayNotification(
151
+ buildNotificationModel(
152
+ "pre-android12-launch-activity",
153
+ buildPressAction("answer", ExplicitLaunchActivity.class.getName(), null))));
154
+
155
+ assertReceiverServiceActionIntent(
156
+ shadowPendingIntent, "answer", ExplicitLaunchActivity.class.getName(), null);
157
+ }
158
+
159
+ @Test
160
+ public void displayNotification_targetBelow31LaunchActivityAction_keepsReceiverServiceRoute()
161
+ throws Exception {
162
+ setTargetSdk(Build.VERSION_CODES.R);
163
+
164
+ ShadowPendingIntent shadowPendingIntent =
165
+ actionPendingIntent(
166
+ displayNotification(
167
+ buildNotificationModel(
168
+ "target-below31-launch-activity",
169
+ buildPressAction("answer", ExplicitLaunchActivity.class.getName(), null))));
170
+
171
+ assertReceiverServiceActionIntent(
172
+ shadowPendingIntent, "answer", ExplicitLaunchActivity.class.getName(), null);
173
+ }
174
+
175
+ private Notification displayNotification(NotificationModel notificationModel) throws Exception {
176
+ ListenableFuture<Void> future =
177
+ NotificationManager.displayNotification(notificationModel, null);
178
+ future.get(5, TimeUnit.SECONDS);
179
+
180
+ android.app.NotificationManager notificationManager =
181
+ (android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
182
+ ShadowNotificationManager shadowNotificationManager = Shadows.shadowOf(notificationManager);
183
+ Notification notification =
184
+ shadowNotificationManager.getNotification(notificationModel.getHashCode());
185
+ assertNotNull("notification must be posted", notification);
186
+ return notification;
187
+ }
188
+
189
+ private static ShadowPendingIntent actionPendingIntent(Notification notification) {
190
+ assertNotNull("action array must be present", notification.actions);
191
+ assertEquals("one action must be built", 1, notification.actions.length);
192
+
193
+ PendingIntent pendingIntent = notification.actions[0].actionIntent;
194
+ assertNotNull("action PendingIntent must be present", pendingIntent);
195
+ return Shadows.shadowOf(pendingIntent);
196
+ }
197
+
198
+ private static void assertReceiverServiceActionIntent(
199
+ ShadowPendingIntent shadowPendingIntent,
200
+ String id,
201
+ String launchActivity,
202
+ String mainComponent) {
203
+ assertTrue("action PendingIntent must target ReceiverService", shadowPendingIntent.isService());
204
+
205
+ Intent intent = shadowPendingIntent.getSavedIntent();
206
+ assertNotNull("service intent must be saved", intent);
207
+ assertEquals(ReceiverService.ACTION_PRESS_INTENT, intent.getAction());
208
+ assertEquals(ReceiverService.class.getName(), intent.getComponent().getClassName());
209
+ assertPressAction(intent.getBundleExtra("pressAction"), id, launchActivity, mainComponent);
210
+ }
211
+
212
+ private static void assertLaunchActivityActionIntent(
213
+ ShadowPendingIntent shadowPendingIntent,
214
+ Class<? extends Activity> launchActivityClass,
215
+ String id,
216
+ String launchActivity,
217
+ String mainComponent) {
218
+ assertTrue(
219
+ "action PendingIntent must use the launch-activity path", shadowPendingIntent.isActivity());
220
+
221
+ Intent[] intents = shadowPendingIntent.getSavedIntents();
222
+ assertNotNull("activity intent stack must be saved", intents);
223
+ assertEquals("launch path must include app launch and receiver activity", 2, intents.length);
224
+ assertEquals(launchActivityClass.getName(), intents[0].getComponent().getClassName());
225
+ assertEquals(
226
+ NotificationReceiverActivity.class.getName(), intents[1].getComponent().getClassName());
227
+ assertEquals(TYPE_ACTION_PRESS, intents[1].getIntExtra(EVENT_TYPE_INTENT_KEY, -1));
228
+ assertPressAction(intents[1].getBundleExtra("pressAction"), id, launchActivity, mainComponent);
229
+ }
230
+
231
+ private static void assertPressAction(
232
+ Bundle pressAction, String id, String launchActivity, String mainComponent) {
233
+ assertNotNull("pressAction extras must be present", pressAction);
234
+ assertEquals(id, pressAction.getString("id"));
235
+ assertEquals(launchActivity, pressAction.getString("launchActivity"));
236
+ assertEquals(mainComponent, pressAction.getString("mainComponent"));
237
+ }
238
+
239
+ private void registerDefaultLaunchActivity() {
240
+ Intent launcherIntent = new Intent(Intent.ACTION_MAIN);
241
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
242
+ launcherIntent.setPackage(context.getPackageName());
243
+
244
+ ResolveInfo resolveInfo = new ResolveInfo();
245
+ resolveInfo.activityInfo = new ActivityInfo();
246
+ resolveInfo.activityInfo.packageName = context.getPackageName();
247
+ resolveInfo.activityInfo.name = DefaultLaunchActivity.class.getName();
248
+
249
+ ShadowPackageManager shadowPackageManager = Shadows.shadowOf(context.getPackageManager());
250
+ shadowPackageManager.addActivityIfNotPresent(
251
+ new ComponentName(context.getPackageName(), DefaultLaunchActivity.class.getName()));
252
+ shadowPackageManager.addResolveInfoForIntent(launcherIntent, resolveInfo);
253
+ }
254
+
255
+ private void createChannel() {
256
+ android.app.NotificationManager notificationManager =
257
+ (android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
258
+ if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
259
+ NotificationChannel channel =
260
+ new NotificationChannel(
261
+ CHANNEL_ID, "Action routing test", android.app.NotificationManager.IMPORTANCE_LOW);
262
+ notificationManager.createNotificationChannel(channel);
263
+ }
264
+ }
265
+
266
+ private static void setTargetSdk(int targetSdk) {
267
+ RuntimeEnvironment.getApplication().getApplicationInfo().targetSdkVersion = targetSdk;
268
+ }
269
+
270
+ private static NotificationModel buildNotificationModel(String id, Bundle pressAction) {
271
+ Bundle notificationBundle = new Bundle();
272
+ notificationBundle.putString("id", id);
273
+ notificationBundle.putString("title", "Action routing " + id);
274
+ notificationBundle.putString("body", "Body " + id);
275
+
276
+ Bundle androidBundle = new Bundle();
277
+ androidBundle.putString("channelId", CHANNEL_ID);
278
+
279
+ Bundle action = new Bundle();
280
+ action.putString("title", "Action " + pressAction.getString("id"));
281
+ action.putBundle("pressAction", pressAction);
282
+ ArrayList<Bundle> actions = new ArrayList<>();
283
+ actions.add(action);
284
+ androidBundle.putParcelableArrayList("actions", actions);
285
+
286
+ notificationBundle.putBundle("android", androidBundle);
287
+ return NotificationModel.fromBundle(notificationBundle);
288
+ }
289
+
290
+ private static Bundle buildPressAction(String id, String launchActivity, String mainComponent) {
291
+ Bundle pressAction = new Bundle();
292
+ pressAction.putString("id", id);
293
+ if (launchActivity != null) {
294
+ pressAction.putString("launchActivity", launchActivity);
295
+ }
296
+ if (mainComponent != null) {
297
+ pressAction.putString("mainComponent", mainComponent);
298
+ }
299
+ return pressAction;
300
+ }
301
+
302
+ public static class DefaultLaunchActivity extends Activity {}
303
+
304
+ public static class ExplicitLaunchActivity extends Activity {}
305
+ }
@@ -8,6 +8,7 @@ import android.app.Activity;
8
8
  import android.content.Context;
9
9
  import android.content.Intent;
10
10
  import android.os.Bundle;
11
+ import androidx.core.app.RemoteInput;
11
12
  import app.notifee.core.event.InitialNotificationEvent;
12
13
  import app.notifee.core.event.MainComponentEvent;
13
14
  import app.notifee.core.event.NotificationEvent;
@@ -73,6 +74,38 @@ public class ReceiverServiceInitialNotificationTest {
73
74
  EventBus.getStickyEvent(InitialNotificationEvent.class));
74
75
  }
75
76
 
77
+ @Test
78
+ public void onActionPressIntent_withoutLaunch_preservesRemoteInputInNotificationEvent() {
79
+ Intent intent =
80
+ buildServiceIntent(
81
+ buildNotificationBundle("service-action-remote-input"),
82
+ buildPressActionBundle("reply", null, null));
83
+ Bundle remoteInputResults = new Bundle();
84
+ remoteInputResults.putCharSequence(ReceiverService.REMOTE_INPUT_RECEIVER_KEY, "Accepted");
85
+ RemoteInput.addResultsToIntent(
86
+ new RemoteInput[] {
87
+ new RemoteInput.Builder(ReceiverService.REMOTE_INPUT_RECEIVER_KEY).build()
88
+ },
89
+ intent,
90
+ remoteInputResults);
91
+
92
+ service.onStartCommand(intent, 0, 1);
93
+
94
+ assertEquals("one NotificationEvent should be posted", 1, capture.events.size());
95
+ NotificationEvent event = capture.events.get(0);
96
+ assertEquals(NotificationEvent.TYPE_ACTION_PRESS, event.getType());
97
+ assertEquals("service-action-remote-input", event.getNotification().getId());
98
+ assertNotNull("ACTION_PRESS extras should be preserved", event.getExtras());
99
+ assertPressAction(event.getExtras(), "reply", null, null);
100
+ assertEquals(
101
+ "remote input result should be preserved",
102
+ "Accepted",
103
+ event.getExtras().getString("input"));
104
+ assertNull(
105
+ "no-launch remote input ACTION_PRESS should not post InitialNotificationEvent",
106
+ EventBus.getStickyEvent(InitialNotificationEvent.class));
107
+ }
108
+
76
109
  @Test
77
110
  public void onActionPressIntent_withLaunch_postsInitialNotificationAfterPendingIntentSend() {
78
111
  Intent intent =
@@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.eq;
7
7
  import static org.mockito.ArgumentMatchers.same;
8
8
  import static org.mockito.Mockito.mock;
9
9
  import static org.mockito.Mockito.never;
10
+ import static org.mockito.Mockito.times;
10
11
  import static org.mockito.Mockito.verify;
11
12
  import static org.mockito.Mockito.when;
12
13
 
@@ -54,11 +55,8 @@ public class HeadlessTaskReactHostTest {
54
55
  HeadlessTask headlessTask = new HeadlessTask();
55
56
  HeadlessTask.TaskConfig taskConfig =
56
57
  new HeadlessTask.TaskConfig("test-headless-task", 60000L, params, null);
57
- ReactContext reactContext = mock(ReactContext.class);
58
58
  AppRegistry appRegistry = mock(AppRegistry.class);
59
- when(reactContext.getLifecycleState()).thenReturn(LifecycleState.BEFORE_RESUME);
60
- when(reactContext.hasActiveReactInstance()).thenReturn(true);
61
- when(reactContext.getJSModule(AppRegistry.class)).thenReturn(appRegistry);
59
+ ReactContext reactContext = createReactContext(appRegistry);
62
60
 
63
61
  WritableMap copiedParams = taskConfig.getTaskConfig().getData();
64
62
 
@@ -73,6 +71,7 @@ public class HeadlessTaskReactHostTest {
73
71
  verify(appRegistry, never())
74
72
  .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
75
73
 
74
+ application.reactHost.currentReactContext = reactContext;
76
75
  ReactInstanceEventListener listener = application.reactHost.listeners.get(0);
77
76
  listener.onReactContextInitialized(reactContext);
78
77
 
@@ -101,6 +100,136 @@ public class HeadlessTaskReactHostTest {
101
100
  copiedParams.getInt("taskId"));
102
101
  }
103
102
 
103
+ @Test
104
+ public void
105
+ startTask_whenInitializedContextDisappears_reinitializesReactHostAndDrainsQueuedTasks() {
106
+ HeadlessTask headlessTask = new HeadlessTask();
107
+ AppRegistry firstAppRegistry = mock(AppRegistry.class);
108
+ AppRegistry secondAppRegistry = mock(AppRegistry.class);
109
+ ReactContext firstReactContext = createReactContext(firstAppRegistry);
110
+ ReactContext secondReactContext = createReactContext(secondAppRegistry);
111
+ HeadlessTask.TaskConfig firstTaskConfig =
112
+ new HeadlessTask.TaskConfig("first-headless-task", 60000L, params("first"), null);
113
+ HeadlessTask.TaskConfig secondTaskConfig =
114
+ new HeadlessTask.TaskConfig("second-headless-task", 60000L, params("second"), null);
115
+ HeadlessTask.TaskConfig thirdTaskConfig =
116
+ new HeadlessTask.TaskConfig("third-headless-task", 60000L, params("third"), null);
117
+ WritableMap firstCopiedParams = firstTaskConfig.getTaskConfig().getData();
118
+ WritableMap secondCopiedParams = secondTaskConfig.getTaskConfig().getData();
119
+ WritableMap thirdCopiedParams = thirdTaskConfig.getTaskConfig().getData();
120
+
121
+ headlessTask.startTask(application, firstTaskConfig);
122
+
123
+ assertEquals(1, application.reactHost.startCalls);
124
+ assertEquals(1, application.reactHost.listeners.size());
125
+
126
+ application.reactHost.currentReactContext = firstReactContext;
127
+ application.reactHost.listeners.get(0).onReactContextInitialized(firstReactContext);
128
+ Shadows.shadowOf(Looper.getMainLooper())
129
+ .idleFor(500, java.util.concurrent.TimeUnit.MILLISECONDS);
130
+
131
+ verify(firstAppRegistry)
132
+ .startHeadlessTask(anyInt(), eq("first-headless-task"), same(firstCopiedParams));
133
+
134
+ application.reactHost.currentReactContext = null;
135
+
136
+ headlessTask.startTask(application, secondTaskConfig);
137
+
138
+ assertEquals(
139
+ "ReactHost should be started again for stale ReactContext",
140
+ 2,
141
+ application.reactHost.startCalls);
142
+ assertEquals(1, application.reactHost.listeners.size());
143
+ verify(secondAppRegistry, never())
144
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
145
+
146
+ headlessTask.startTask(application, thirdTaskConfig);
147
+
148
+ assertEquals(
149
+ "ReactHost should not be started again while reinitialization is pending",
150
+ 2,
151
+ application.reactHost.startCalls);
152
+ verify(secondAppRegistry, never())
153
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
154
+
155
+ application.reactHost.currentReactContext = secondReactContext;
156
+ application.reactHost.listeners.get(0).onReactContextInitialized(secondReactContext);
157
+ Shadows.shadowOf(Looper.getMainLooper())
158
+ .idleFor(499, java.util.concurrent.TimeUnit.MILLISECONDS);
159
+ verify(secondAppRegistry, never())
160
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
161
+
162
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, java.util.concurrent.TimeUnit.MILLISECONDS);
163
+
164
+ verify(firstAppRegistry, times(1))
165
+ .startHeadlessTask(anyInt(), eq("first-headless-task"), same(firstCopiedParams));
166
+ verify(secondAppRegistry)
167
+ .startHeadlessTask(anyInt(), eq("second-headless-task"), same(secondCopiedParams));
168
+ verify(secondAppRegistry)
169
+ .startHeadlessTask(anyInt(), eq("third-headless-task"), same(thirdCopiedParams));
170
+ }
171
+
172
+ @Test
173
+ public void startTask_whenContextDisappearsBeforeInitialDrain_waitsForRecoveredReactContext() {
174
+ HeadlessTask headlessTask = new HeadlessTask();
175
+ AppRegistry staleAppRegistry = mock(AppRegistry.class);
176
+ AppRegistry recoveredAppRegistry = mock(AppRegistry.class);
177
+ ReactContext staleReactContext = createReactContext(staleAppRegistry);
178
+ ReactContext recoveredReactContext = createReactContext(recoveredAppRegistry);
179
+ HeadlessTask.TaskConfig firstTaskConfig =
180
+ new HeadlessTask.TaskConfig("pending-first-headless-task", 60000L, params("first"), null);
181
+ HeadlessTask.TaskConfig secondTaskConfig =
182
+ new HeadlessTask.TaskConfig("pending-second-headless-task", 60000L, params("second"), null);
183
+ WritableMap firstCopiedParams = firstTaskConfig.getTaskConfig().getData();
184
+ WritableMap secondCopiedParams = secondTaskConfig.getTaskConfig().getData();
185
+
186
+ headlessTask.startTask(application, firstTaskConfig);
187
+ application.reactHost.currentReactContext = staleReactContext;
188
+ application.reactHost.listeners.get(0).onReactContextInitialized(staleReactContext);
189
+
190
+ application.reactHost.currentReactContext = null;
191
+ headlessTask.startTask(application, secondTaskConfig);
192
+
193
+ assertEquals(
194
+ "ReactHost should be restarted when the pending context disappears",
195
+ 2,
196
+ application.reactHost.startCalls);
197
+ assertEquals(1, application.reactHost.listeners.size());
198
+
199
+ Shadows.shadowOf(Looper.getMainLooper())
200
+ .idleFor(500, java.util.concurrent.TimeUnit.MILLISECONDS);
201
+ verify(staleAppRegistry, never())
202
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
203
+ verify(recoveredAppRegistry, never())
204
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
205
+
206
+ application.reactHost.currentReactContext = recoveredReactContext;
207
+ application.reactHost.listeners.get(0).onReactContextInitialized(recoveredReactContext);
208
+ Shadows.shadowOf(Looper.getMainLooper())
209
+ .idleFor(500, java.util.concurrent.TimeUnit.MILLISECONDS);
210
+
211
+ verify(staleAppRegistry, never())
212
+ .startHeadlessTask(anyInt(), any(String.class), any(WritableMap.class));
213
+ verify(recoveredAppRegistry)
214
+ .startHeadlessTask(anyInt(), eq("pending-first-headless-task"), same(firstCopiedParams));
215
+ verify(recoveredAppRegistry)
216
+ .startHeadlessTask(anyInt(), eq("pending-second-headless-task"), same(secondCopiedParams));
217
+ }
218
+
219
+ private static JavaOnlyMap params(String source) {
220
+ JavaOnlyMap params = new JavaOnlyMap();
221
+ params.putString("source", source);
222
+ return params;
223
+ }
224
+
225
+ private static ReactContext createReactContext(AppRegistry appRegistry) {
226
+ ReactContext reactContext = mock(ReactContext.class);
227
+ when(reactContext.getLifecycleState()).thenReturn(LifecycleState.BEFORE_RESUME);
228
+ when(reactContext.hasActiveReactInstance()).thenReturn(true);
229
+ when(reactContext.getJSModule(AppRegistry.class)).thenReturn(appRegistry);
230
+ return reactContext;
231
+ }
232
+
104
233
  public static class TestApplication extends Application {
105
234
  final TestReactHost reactHost = new TestReactHost();
106
235
 
@@ -111,10 +240,11 @@ public class HeadlessTaskReactHostTest {
111
240
 
112
241
  public static class TestReactHost {
113
242
  int startCalls;
243
+ ReactContext currentReactContext;
114
244
  final List<ReactInstanceEventListener> listeners = new ArrayList<>();
115
245
 
116
246
  public ReactContext getCurrentReactContext() {
117
- return null;
247
+ return currentReactContext;
118
248
  }
119
249
 
120
250
  public void addReactInstanceEventListener(ReactInstanceEventListener listener) {
@@ -131,6 +261,7 @@ public class HeadlessTaskReactHostTest {
131
261
 
132
262
  void reset() {
133
263
  startCalls = 0;
264
+ currentReactContext = null;
134
265
  listeners.clear();
135
266
  }
136
267
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "10.4.3";
1
+ export declare const version = "10.4.5";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by genversion.
2
- export const version = '10.4.3';
2
+ export const version = '10.4.5';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-notify-kit",
3
- "version": "10.4.3",
3
+ "version": "10.4.5",
4
4
  "author": "Marco Crupi",
5
5
  "description": "Maintained Notifee-compatible React Native notifications library for Android, iOS, FCM Mode, rich notifications, foreground services, and Expo CNG development builds.",
6
6
  "main": "dist/index.js",
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '10.4.3';
2
+ export const version = '10.4.5';