react-native-notify-kit 10.4.4 → 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 +43 -7
- package/android/src/main/java/app/notifee/core/NotificationManager.java +8 -4
- package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +23 -7
- package/android/src/test/java/app/notifee/core/NotificationManagerActionRoutingTest.java +305 -0
- package/android/src/test/java/app/notifee/core/ReceiverServiceInitialNotificationTest.java +33 -0
- package/android/src/test/java/io/invertase/notifee/HeadlessTaskReactHostTest.java +136 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -499,7 +499,7 @@ This fork fixes the following bugs that were never resolved in the original Noti
|
|
|
499
499
|
|
|
500
500
|
<!-- markdownlint-disable-line MD028 -->
|
|
501
501
|
|
|
502
|
-
> **Note for apps requiring
|
|
502
|
+
> **Note for policy-eligible apps requiring exact alarm APIs (alarm clocks, timers, calendars):**
|
|
503
503
|
> Add `<uses-permission android:name="android.permission.USE_EXACT_ALARM" />` to your app's
|
|
504
504
|
> `AndroidManifest.xml`. This permission is auto-granted and not revocable, but Google Play
|
|
505
505
|
> restricts its use to apps whose core function requires exact timing.
|
|
@@ -659,6 +659,13 @@ The original Notifee used WorkManager by default, which is battery-friendly but
|
|
|
659
659
|
for time-sensitive notifications — Android may defer or drop WorkManager tasks based on
|
|
660
660
|
battery optimization, Doze mode, and OEM power management.
|
|
661
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
|
+
|
|
662
669
|
If you need battery-friendly scheduling where exact timing is not critical (e.g., daily digest
|
|
663
670
|
notifications), you can opt out:
|
|
664
671
|
|
|
@@ -719,12 +726,14 @@ await notifee.createTriggerNotification(
|
|
|
719
726
|
);
|
|
720
727
|
```
|
|
721
728
|
|
|
722
|
-
**
|
|
723
|
-
`SET_ALARM_CLOCK` all require
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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.
|
|
728
737
|
|
|
729
738
|
### Timers: foreground service or `SET_ALARM_CLOCK`?
|
|
730
739
|
|
|
@@ -810,6 +819,33 @@ Default is `true` (backward compatible — Notifee handles everything, same as o
|
|
|
810
819
|
|
|
811
820
|
### Troubleshooting
|
|
812
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
|
+
|
|
813
849
|
#### Small icon not showing in Android release builds (falls back to launcher icon)
|
|
814
850
|
|
|
815
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.
|
|
@@ -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
|
-
|
|
463
|
+
pressAction.toBundle(),
|
|
460
464
|
TYPE_ACTION_PRESS,
|
|
461
465
|
new String[] {"notification", "pressAction"},
|
|
462
466
|
notificationModel.toBundle(),
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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(
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1
|
+
export declare const version = "10.4.5";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-notify-kit",
|
|
3
|
-
"version": "10.4.
|
|
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.
|
|
2
|
+
export const version = '10.4.5';
|