react-native-notify-kit 10.8.1 → 10.9.1
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 +35 -18
- package/android/build.gradle +50 -4
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/app/notifee/core/EventBus.java +4 -0
- package/android/src/main/java/app/notifee/core/ForegroundService.java +395 -5
- package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitReceiver.java +15 -0
- package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitScheduler.java +461 -0
- package/android/src/main/java/app/notifee/core/FullScreenNotificationActivity.java +76 -0
- package/android/src/main/java/app/notifee/core/InitialNotificationLedger.java +262 -0
- package/android/src/main/java/app/notifee/core/Notifee.java +142 -20
- package/android/src/main/java/app/notifee/core/NotificationManager.java +50 -6
- package/android/src/main/java/app/notifee/core/NotificationPendingIntent.java +14 -0
- package/android/src/main/java/app/notifee/core/NotificationReceiverHandler.java +11 -2
- package/android/src/main/java/app/notifee/core/event/InitialNotificationEvent.java +15 -0
- package/android/src/main/java/app/notifee/core/event/NotificationEvent.java +6 -0
- package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +29 -0
- package/android/src/main/kotlin/io/invertase/notifee/NotifeeEventSubscriber.kt +3 -0
- package/android/src/test/java/app/notifee/core/ForegroundServiceRunLimitTest.java +2357 -0
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +23 -13
- package/android/src/test/java/app/notifee/core/NotifeeGetInitialNotificationTest.java +3805 -0
- package/android/src/test/java/app/notifee/core/ShadowServiceCompatibilityTest.java +170 -0
- package/android/src/test/resources/robolectric.properties +4 -0
- package/cli/dist/cli.js +0 -1
- package/cli/dist/commands/initNse.js +1 -2
- package/cli/dist/lib/detectProject.js +1 -2
- package/cli/dist/lib/initNseCore.js +0 -1
- package/cli/dist/lib/logger.js +0 -1
- package/cli/dist/lib/patchPodfile.js +0 -1
- package/cli/dist/lib/patchXcodeProject.js +22 -3
- package/cli/dist/lib/writeTemplates.js +0 -1
- package/cli/dist/lib/xcode.js +6 -0
- package/cli/dist/templates/NotificationService.swift.tmpl +74 -6
- package/cli/package.json +1 -0
- package/cli/vendor/xcode/AUTHORS +6 -0
- package/cli/vendor/xcode/LICENSE +202 -0
- package/cli/vendor/xcode/NOTICE +5 -0
- package/cli/vendor/xcode/README.md +32 -0
- package/cli/vendor/xcode/UPSTREAM-SHA256.json +12 -0
- package/cli/vendor/xcode/index.js +18 -0
- package/cli/vendor/xcode/lib/parseJob.js +32 -0
- package/cli/vendor/xcode/lib/parser/pbxproj.js +1904 -0
- package/cli/vendor/xcode/lib/parser/pbxproj.pegjs +280 -0
- package/cli/vendor/xcode/lib/pbxFile.js +233 -0
- package/cli/vendor/xcode/lib/pbxProject.js +2205 -0
- package/cli/vendor/xcode/lib/pbxWriter.js +309 -0
- package/dist/NotifeeNativeModule.web.d.ts +2 -2
- package/dist/types/Module.d.ts +10 -3
- package/dist/types/Notification.d.ts +12 -1
- package/dist/types/Notification.js +3 -0
- package/dist/types/Notification.js.map +1 -1
- package/dist/types/NotificationAndroid.d.ts +9 -0
- package/dist/types/NotificationAndroid.js.map +1 -1
- package/dist/validators/validateAndroidNotification.js +26 -1
- package/dist/validators/validateAndroidNotification.js.map +1 -1
- package/dist/validators/validateAndroidStyle.js +6 -0
- package/dist/validators/validateAndroidStyle.js.map +1 -1
- package/dist/validators/validateIOSAttachment.js +3 -1
- package/dist/validators/validateIOSAttachment.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -5
- package/src/NotifeeNativeModule.web.ts +2 -2
- package/src/types/Module.ts +10 -3
- package/src/types/Notification.ts +13 -0
- package/src/types/NotificationAndroid.ts +10 -0
- package/src/validators/validateAndroidNotification.ts +31 -1
- package/src/validators/validateAndroidStyle.ts +10 -2
- package/src/validators/validateIOSAttachment.ts +5 -1
- package/src/version.ts +1 -1
- package/cli/dist/cli.bundle.js +0 -20834
- package/cli/dist/cli.d.ts +0 -2
- package/cli/dist/cli.js.map +0 -1
- package/cli/dist/commands/initNse.d.ts +0 -12
- package/cli/dist/commands/initNse.js.map +0 -1
- package/cli/dist/lib/detectProject.d.ts +0 -12
- package/cli/dist/lib/detectProject.js.map +0 -1
- package/cli/dist/lib/initNseCore.d.ts +0 -8
- package/cli/dist/lib/initNseCore.js.map +0 -1
- package/cli/dist/lib/logger.d.ts +0 -4
- package/cli/dist/lib/logger.js.map +0 -1
- package/cli/dist/lib/patchPodfile.d.ts +0 -23
- package/cli/dist/lib/patchPodfile.js.map +0 -1
- package/cli/dist/lib/patchXcodeProject.d.ts +0 -41
- package/cli/dist/lib/patchXcodeProject.js.map +0 -1
- package/cli/dist/lib/writeTemplates.d.ts +0 -11
- package/cli/dist/lib/writeTemplates.js.map +0 -1
- package/cli/dist/templates/templates/Info.plist.tmpl +0 -29
- package/cli/dist/templates/templates/NotificationService.swift.tmpl +0 -100
- package/cli/dist/templates/templates/NotifyKitNSE.entitlements.tmpl +0 -6
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.content.SharedPreferences;
|
|
5
|
+
import android.os.SystemClock;
|
|
6
|
+
import androidx.annotation.Nullable;
|
|
7
|
+
import androidx.annotation.VisibleForTesting;
|
|
8
|
+
import java.util.Collections;
|
|
9
|
+
import java.util.HashSet;
|
|
10
|
+
import java.util.Set;
|
|
11
|
+
import java.util.UUID;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Durable record of the interactions returned by {@link Notifee#getInitialNotification}, so each
|
|
15
|
+
* one is returned at most once, also when its Activity is recreated in a new process (#88).
|
|
16
|
+
*
|
|
17
|
+
* <p>Taps of {@link NotificationPendingIntent} launch flows are numbered by {@link
|
|
18
|
+
* NotificationReceiverHandler}. The number travels with the sticky {@code InitialNotificationEvent}
|
|
19
|
+
* and is stored with the id of the tapped PendingIntent; like the sticky event, only the latest tap
|
|
20
|
+
* is kept. The sticky event and the launch Intent of a tap claim the same number, so the tap is
|
|
21
|
+
* returned by whichever is read first. A launch Intent can only claim the latest tap of its own
|
|
22
|
+
* PendingIntent, which it gets once the sticky event has been lost with the process that received
|
|
23
|
+
* it.
|
|
24
|
+
*
|
|
25
|
+
* <p>Full-screen launches are not numbered: each display issues its own id, recorded as unclaimed
|
|
26
|
+
* before the notification is posted. A claim removes the id, so an id already returned, or never
|
|
27
|
+
* issued, is not returned, however many launches follow. Ids issued before the device last rebooted
|
|
28
|
+
* are dropped when the next id is issued, as no Intent extras survive a reboot. A new delivered
|
|
29
|
+
* launch retires the previous delivered launch before its in-memory payload is replaced.
|
|
30
|
+
*
|
|
31
|
+
* <p>Claims are committed before the interaction is returned.
|
|
32
|
+
*/
|
|
33
|
+
final class InitialNotificationLedger {
|
|
34
|
+
private static final String TAG = "InitialNotificationLedger";
|
|
35
|
+
private static final String PREFERENCES_FILE = "app.notifee.core.initial_notification";
|
|
36
|
+
private static final String LAST_TAP_SEQUENCE_KEY = "last_tap_sequence";
|
|
37
|
+
private static final String LAST_TAP_PENDING_INTENT_ID_KEY = "last_tap_pending_intent_id";
|
|
38
|
+
private static final String CLAIMED_TAP_SEQUENCE_KEY = "claimed_tap_sequence";
|
|
39
|
+
private static final String UNCLAIMED_FULL_SCREEN_IDS_KEY = "unclaimed_full_screen_ids";
|
|
40
|
+
private static final String LAST_FULL_SCREEN_LAUNCH_ID_KEY = "last_full_screen_launch_id";
|
|
41
|
+
private static final String FULL_SCREEN_ISSUE_UPTIME_KEY = "full_screen_issue_uptime";
|
|
42
|
+
|
|
43
|
+
private static final Object lock = new Object();
|
|
44
|
+
|
|
45
|
+
private InitialNotificationLedger() {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records a tap before its sticky event is posted.
|
|
49
|
+
*
|
|
50
|
+
* @return the tap's sequence number, or 0 if it was not recorded (the tap is then returned by its
|
|
51
|
+
* sticky event only).
|
|
52
|
+
*/
|
|
53
|
+
static long recordTap(@Nullable String pendingIntentId) {
|
|
54
|
+
synchronized (lock) {
|
|
55
|
+
try {
|
|
56
|
+
SharedPreferences preferences = getPreferences();
|
|
57
|
+
long sequence = preferences.getLong(LAST_TAP_SEQUENCE_KEY, 0) + 1;
|
|
58
|
+
// Main thread, so not committed: a record lost with the process only means the tap cannot
|
|
59
|
+
// be recovered from its launch Intent; it is never returned twice.
|
|
60
|
+
preferences
|
|
61
|
+
.edit()
|
|
62
|
+
.putLong(LAST_TAP_SEQUENCE_KEY, sequence)
|
|
63
|
+
.putString(LAST_TAP_PENDING_INTENT_ID_KEY, pendingIntentId)
|
|
64
|
+
.apply();
|
|
65
|
+
return sequence;
|
|
66
|
+
} catch (Exception e) {
|
|
67
|
+
Logger.e(TAG, "Failed to record notification tap", e);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
enum TapClaim {
|
|
74
|
+
CLAIMED,
|
|
75
|
+
ALREADY_CLAIMED,
|
|
76
|
+
RETRY
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Claims a sticky tap durably, or leaves it pending when storage is unavailable. */
|
|
80
|
+
static TapClaim claimTap(long tapSequence) {
|
|
81
|
+
if (tapSequence <= 0) {
|
|
82
|
+
return TapClaim.CLAIMED;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
synchronized (lock) {
|
|
86
|
+
try {
|
|
87
|
+
SharedPreferences preferences = getPreferences();
|
|
88
|
+
long claimed = preferences.getLong(CLAIMED_TAP_SEQUENCE_KEY, 0);
|
|
89
|
+
if (tapSequence <= claimed) {
|
|
90
|
+
return TapClaim.ALREADY_CLAIMED;
|
|
91
|
+
}
|
|
92
|
+
if (preferences.edit().putLong(CLAIMED_TAP_SEQUENCE_KEY, tapSequence).commit()) {
|
|
93
|
+
return TapClaim.CLAIMED;
|
|
94
|
+
}
|
|
95
|
+
// A failed edit still advances the in-memory value. Keep the interaction retryable,
|
|
96
|
+
// without returning a tap that a later process could return again.
|
|
97
|
+
preferences.edit().putLong(CLAIMED_TAP_SEQUENCE_KEY, claimed).apply();
|
|
98
|
+
Logger.w(TAG, "Failed to persist notification tap claim");
|
|
99
|
+
} catch (Exception e) {
|
|
100
|
+
Logger.e(TAG, "Failed to claim notification tap", e);
|
|
101
|
+
}
|
|
102
|
+
return TapClaim.RETRY;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Claims the latest tap for a launch Intent of {@code pendingIntentId}, if not returned yet. */
|
|
107
|
+
static boolean claimLatestTap(String pendingIntentId) {
|
|
108
|
+
synchronized (lock) {
|
|
109
|
+
try {
|
|
110
|
+
SharedPreferences preferences = getPreferences();
|
|
111
|
+
long latest = preferences.getLong(LAST_TAP_SEQUENCE_KEY, 0);
|
|
112
|
+
if (latest <= preferences.getLong(CLAIMED_TAP_SEQUENCE_KEY, 0)
|
|
113
|
+
|| !pendingIntentId.equals(
|
|
114
|
+
preferences.getString(LAST_TAP_PENDING_INTENT_ID_KEY, null))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return claimTap(latest) == TapClaim.CLAIMED;
|
|
118
|
+
} catch (Exception e) {
|
|
119
|
+
Logger.e(TAG, "Failed to claim notification tap", e);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Issues the id of a full-screen launch Intent and records it as unclaimed, before the
|
|
127
|
+
* notification is posted.
|
|
128
|
+
*
|
|
129
|
+
* @return the id; one that could not be recorded is never claimed.
|
|
130
|
+
*/
|
|
131
|
+
static String issueFullScreenLaunchId() {
|
|
132
|
+
String id = UUID.randomUUID().toString();
|
|
133
|
+
synchronized (lock) {
|
|
134
|
+
try {
|
|
135
|
+
SharedPreferences preferences = getPreferences();
|
|
136
|
+
long uptime = SystemClock.elapsedRealtime();
|
|
137
|
+
Set<String> unclaimed = new HashSet<>();
|
|
138
|
+
// Uptime restarts at boot: below the uptime of the last issue, every id issued so far
|
|
139
|
+
// predates a reboot.
|
|
140
|
+
if (uptime >= preferences.getLong(FULL_SCREEN_ISSUE_UPTIME_KEY, 0)) {
|
|
141
|
+
unclaimed.addAll(
|
|
142
|
+
preferences.getStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, Collections.emptySet()));
|
|
143
|
+
}
|
|
144
|
+
unclaimed.add(id);
|
|
145
|
+
if (!preferences
|
|
146
|
+
.edit()
|
|
147
|
+
.putStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, unclaimed)
|
|
148
|
+
.putLong(FULL_SCREEN_ISSUE_UPTIME_KEY, uptime)
|
|
149
|
+
.commit()) {
|
|
150
|
+
// The id is claimable in this process only.
|
|
151
|
+
Logger.w(TAG, "Failed to persist full-screen launch");
|
|
152
|
+
}
|
|
153
|
+
} catch (Exception e) {
|
|
154
|
+
Logger.e(TAG, "Failed to record full-screen launch", e);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Records a delivered full-screen launch and retires the previous delivered one. Only ids are
|
|
162
|
+
* persisted: an earlier Activity creation Intent must stay retired even when this process,
|
|
163
|
+
* including the payload of the replacement, is lost before the next read. Displayed notifications
|
|
164
|
+
* that have not launched are left alone.
|
|
165
|
+
*
|
|
166
|
+
* @return false for an unknown/claimed id. A failed write keeps the replacement in memory; its
|
|
167
|
+
* claim must persist it before returning the interaction.
|
|
168
|
+
*/
|
|
169
|
+
static boolean recordFullScreenLaunch(String pendingIntentId) {
|
|
170
|
+
synchronized (lock) {
|
|
171
|
+
try {
|
|
172
|
+
SharedPreferences preferences = getPreferences();
|
|
173
|
+
Set<String> unclaimed =
|
|
174
|
+
preferences.getStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, Collections.emptySet());
|
|
175
|
+
if (!unclaimed.contains(pendingIntentId)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
String previousId = preferences.getString(LAST_FULL_SCREEN_LAUNCH_ID_KEY, null);
|
|
179
|
+
if (pendingIntentId.equals(previousId)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
Set<String> remaining = new HashSet<>(unclaimed);
|
|
183
|
+
remaining.remove(previousId);
|
|
184
|
+
if (preferences
|
|
185
|
+
.edit()
|
|
186
|
+
.putStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, remaining)
|
|
187
|
+
.putString(LAST_FULL_SCREEN_LAUNCH_ID_KEY, pendingIntentId)
|
|
188
|
+
.commit()) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
// The edit still replaces the predecessor in memory. Keep that state and the payload:
|
|
192
|
+
// a later successful claim writes the whole preferences file, including all retirements,
|
|
193
|
+
// before returning the replacement. Rolling back here would lose a valid incoming launch.
|
|
194
|
+
// If the process dies before any write succeeds, only the earlier durable state survives.
|
|
195
|
+
Logger.w(TAG, "Failed to persist full-screen launch replacement");
|
|
196
|
+
return true;
|
|
197
|
+
} catch (Exception e) {
|
|
198
|
+
Logger.e(TAG, "Failed to record full-screen launch", e);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Claims a full-screen launch, if its id was issued and not returned yet. */
|
|
205
|
+
static boolean claimFullScreenLaunch(String pendingIntentId) {
|
|
206
|
+
synchronized (lock) {
|
|
207
|
+
try {
|
|
208
|
+
SharedPreferences preferences = getPreferences();
|
|
209
|
+
Set<String> unclaimed =
|
|
210
|
+
preferences.getStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, Collections.emptySet());
|
|
211
|
+
if (!unclaimed.contains(pendingIntentId)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
Set<String> remaining = new HashSet<>(unclaimed);
|
|
216
|
+
remaining.remove(pendingIntentId);
|
|
217
|
+
if (preferences.edit().putStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, remaining).commit()) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
// A claim missing from disk could be returned again by a later process. The failed edit
|
|
221
|
+
// still removed the id in memory: put it back so that a later call can claim it.
|
|
222
|
+
preferences
|
|
223
|
+
.edit()
|
|
224
|
+
.putStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, new HashSet<>(unclaimed))
|
|
225
|
+
.apply();
|
|
226
|
+
Logger.w(TAG, "Failed to persist full-screen launch claim");
|
|
227
|
+
return false;
|
|
228
|
+
} catch (Exception e) {
|
|
229
|
+
Logger.e(TAG, "Failed to claim full-screen launch", e);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@VisibleForTesting
|
|
236
|
+
static int unclaimedFullScreenLaunchCountForTesting() {
|
|
237
|
+
synchronized (lock) {
|
|
238
|
+
return getPreferences()
|
|
239
|
+
.getStringSet(UNCLAIMED_FULL_SCREEN_IDS_KEY, Collections.emptySet())
|
|
240
|
+
.size();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@VisibleForTesting
|
|
245
|
+
static void clearForTesting() {
|
|
246
|
+
synchronized (lock) {
|
|
247
|
+
commit(getPreferences().edit().clear());
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private static void commit(SharedPreferences.Editor editor) {
|
|
252
|
+
// The claim already holds in memory; only a later process could return it again.
|
|
253
|
+
if (!editor.commit()) {
|
|
254
|
+
Logger.w(TAG, "Failed to persist initial notification claim");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private static SharedPreferences getPreferences() {
|
|
259
|
+
return ContextHolder.getApplicationContext()
|
|
260
|
+
.getSharedPreferences(PREFERENCES_FILE, Context.MODE_PRIVATE);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -27,6 +27,7 @@ import android.os.Bundle;
|
|
|
27
27
|
import android.provider.Settings;
|
|
28
28
|
import androidx.annotation.NonNull;
|
|
29
29
|
import androidx.annotation.Nullable;
|
|
30
|
+
import androidx.annotation.VisibleForTesting;
|
|
30
31
|
import androidx.core.app.NotificationManagerCompat;
|
|
31
32
|
import app.notifee.core.event.InitialNotificationEvent;
|
|
32
33
|
import app.notifee.core.event.MainComponentEvent;
|
|
@@ -43,7 +44,10 @@ import com.google.common.util.concurrent.Futures;
|
|
|
43
44
|
import com.google.common.util.concurrent.ListeningExecutorService;
|
|
44
45
|
import com.google.common.util.concurrent.MoreExecutors;
|
|
45
46
|
import java.util.ArrayList;
|
|
47
|
+
import java.util.Collections;
|
|
46
48
|
import java.util.List;
|
|
49
|
+
import java.util.Set;
|
|
50
|
+
import java.util.WeakHashMap;
|
|
47
51
|
import java.util.concurrent.ExecutorService;
|
|
48
52
|
import java.util.concurrent.Executors;
|
|
49
53
|
|
|
@@ -57,6 +61,14 @@ public class Notifee {
|
|
|
57
61
|
private static final ListeningExecutorService lExecutorService =
|
|
58
62
|
MoreExecutors.listeningDecorator(executorService);
|
|
59
63
|
|
|
64
|
+
private static final Object initialNotificationLock = new Object();
|
|
65
|
+
// Activity Intents without an interaction id already returned by getInitialNotification().
|
|
66
|
+
// Intent has identity equality; weak keys let entries go with their Activity.
|
|
67
|
+
private static final Set<Intent> consumedInitialNotificationIntents =
|
|
68
|
+
Collections.newSetFromMap(new WeakHashMap<>());
|
|
69
|
+
// Latest full-screen launch recorded by NotificationReceiverActivity, not returned yet.
|
|
70
|
+
@Nullable private static Intent recordedFullScreenLaunchIntent;
|
|
71
|
+
|
|
60
72
|
@KeepForSdk public static final int REQUEST_CODE_NOTIFICATION_PERMISSION = 11111;
|
|
61
73
|
|
|
62
74
|
@KeepForSdk
|
|
@@ -432,31 +444,141 @@ public class Notifee {
|
|
|
432
444
|
|
|
433
445
|
@KeepForSdk
|
|
434
446
|
public void getInitialNotification(Activity activity, MethodCallResult<Bundle> result) {
|
|
435
|
-
|
|
436
|
-
|
|
447
|
+
Bundle initialNotificationBundle;
|
|
448
|
+
synchronized (initialNotificationLock) {
|
|
449
|
+
initialNotificationBundle = consumeInitialNotification(activity);
|
|
450
|
+
}
|
|
451
|
+
result.onComplete(null, initialNotificationBundle);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Checks payload shape before any capture or durable retirement. */
|
|
455
|
+
static boolean isValidFullScreenLaunch(@Nullable Intent intent) {
|
|
456
|
+
try {
|
|
457
|
+
if (intent == null
|
|
458
|
+
|| intent.hasExtra(NotificationPendingIntent.EVENT_TYPE_INTENT_KEY)
|
|
459
|
+
|| intent.getBundleExtra("notification") == null) return false;
|
|
460
|
+
Object notificationId = intent.getBundleExtra("notification").get("id");
|
|
461
|
+
return notificationId instanceof String
|
|
462
|
+
&& !((String) notificationId).isEmpty()
|
|
463
|
+
&& intent.getStringExtra(NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY) != null;
|
|
464
|
+
} catch (Exception e) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
437
468
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Records the launch of a full-screen Intent, from {@link FullScreenNotificationActivity} started
|
|
471
|
+
* before its consumer, independently of React Native. An existing launch Activity gets the launch
|
|
472
|
+
* through onNewIntent(), which React Native drops while its instance starts, and its getIntent()
|
|
473
|
+
* keeps returning the Intent that created it. A launch is recorded only if it was issued and not
|
|
474
|
+
* returned yet, in place of an earlier one, as the sticky event keeps the latest tap.
|
|
475
|
+
*/
|
|
476
|
+
static void recordFullScreenLaunch(@Nullable Intent intent) {
|
|
477
|
+
try {
|
|
478
|
+
if (!isValidFullScreenLaunch(intent)) return;
|
|
479
|
+
String pendingIntentId =
|
|
480
|
+
intent.getStringExtra(NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY);
|
|
481
|
+
|
|
482
|
+
synchronized (initialNotificationLock) {
|
|
483
|
+
if (InitialNotificationLedger.recordFullScreenLaunch(pendingIntentId)) {
|
|
484
|
+
recordedFullScreenLaunchIntent = new Intent(intent);
|
|
485
|
+
String mainComponent = intent.getStringExtra("mainComponent");
|
|
486
|
+
if (mainComponent != null) {
|
|
487
|
+
// A cold process lost the event posted when the notification was displayed. Restore
|
|
488
|
+
// it before the consumer asks for its React component during Activity creation.
|
|
489
|
+
EventBus.postSticky(new MainComponentEvent(mainComponent));
|
|
490
|
+
}
|
|
452
491
|
}
|
|
453
|
-
} catch (Exception e) {
|
|
454
|
-
Logger.e(TAG, "getInitialNotification", e);
|
|
455
492
|
}
|
|
493
|
+
} catch (Exception e) {
|
|
494
|
+
Logger.e(TAG, "recordFullScreenLaunch", e);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private static @Nullable Bundle consumeInitialNotification(@Nullable Activity activity) {
|
|
499
|
+
InitialNotificationEvent event = EventBus.getStickyEvent(InitialNotificationEvent.class);
|
|
500
|
+
|
|
501
|
+
if (event != null) {
|
|
502
|
+
InitialNotificationLedger.TapClaim claim =
|
|
503
|
+
InitialNotificationLedger.claimTap(event.getTapSequence());
|
|
504
|
+
if (claim == InitialNotificationLedger.TapClaim.RETRY) {
|
|
505
|
+
// Keep the sticky payload, including ACTION_PRESS input, for a later durable claim.
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
// Remove only this event: a concurrent new tap must not be discarded with it.
|
|
509
|
+
EventBus.removeStickyEvent(event);
|
|
510
|
+
if (claim == InitialNotificationLedger.TapClaim.CLAIMED) {
|
|
511
|
+
Bundle initialNotificationBundle = new Bundle();
|
|
512
|
+
initialNotificationBundle.putAll(event.getExtras());
|
|
513
|
+
initialNotificationBundle.putBundle(
|
|
514
|
+
"notification", event.getNotificationModel().toBundle());
|
|
515
|
+
return initialNotificationBundle;
|
|
516
|
+
}
|
|
517
|
+
// Otherwise this tap was already returned through its launch Intent.
|
|
456
518
|
}
|
|
457
519
|
|
|
458
|
-
|
|
459
|
-
|
|
520
|
+
Bundle recordedLaunchBundle = consumeActivityIntent(recordedFullScreenLaunchIntent);
|
|
521
|
+
if (recordedLaunchBundle != null) {
|
|
522
|
+
recordedFullScreenLaunchIntent = null;
|
|
523
|
+
return recordedLaunchBundle;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (activity == null) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// get intent from current activity
|
|
531
|
+
return consumeActivityIntent(activity.getIntent());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private static @Nullable Bundle consumeActivityIntent(@Nullable Intent intent) {
|
|
535
|
+
if (intent == null) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
Bundle notification = intent.getBundleExtra("notification");
|
|
541
|
+
if (notification == null || !claimActivityIntent(intent)) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
Bundle initialNotificationBundle = new Bundle();
|
|
546
|
+
initialNotificationBundle.putBundle("notification", notification);
|
|
547
|
+
return initialNotificationBundle;
|
|
548
|
+
} catch (Exception e) {
|
|
549
|
+
Logger.e(TAG, "getInitialNotification", e);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Claims the interaction an Activity Intent carries, if it was not returned yet. */
|
|
556
|
+
private static boolean claimActivityIntent(Intent intent) {
|
|
557
|
+
String pendingIntentId =
|
|
558
|
+
intent.getStringExtra(NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY);
|
|
559
|
+
|
|
560
|
+
if (intent.hasExtra(NotificationPendingIntent.EVENT_TYPE_INTENT_KEY)) {
|
|
561
|
+
// Launch Intent of a NotificationPendingIntent tap. The tap's sticky event returns it; the
|
|
562
|
+
// Intent stands in only for a recorded tap whose event was lost with its process.
|
|
563
|
+
return pendingIntentId != null && InitialNotificationLedger.claimLatestTap(pendingIntentId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (pendingIntentId != null) {
|
|
567
|
+
return InitialNotificationLedger.claimFullScreenLaunch(pendingIntentId);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// No id (foreign Intent, or full-screen Intent of an earlier version): consumption follows the
|
|
571
|
+
// Intent object.
|
|
572
|
+
return consumedInitialNotificationIntents.add(intent);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Drops in-memory state, as a new process would. */
|
|
576
|
+
@VisibleForTesting
|
|
577
|
+
static void resetInitialNotificationStateForTesting() {
|
|
578
|
+
synchronized (initialNotificationLock) {
|
|
579
|
+
consumedInitialNotificationIntents.clear();
|
|
580
|
+
recordedFullScreenLaunchIntent = null;
|
|
581
|
+
}
|
|
460
582
|
}
|
|
461
583
|
|
|
462
584
|
@KeepForSdk
|
|
@@ -415,16 +415,54 @@ class NotificationManager {
|
|
|
415
415
|
launchIntent.putExtra(
|
|
416
416
|
"mainComponent", fullScreenActionBundle.getMainComponent());
|
|
417
417
|
launchIntent.putExtra("notification", notificationModel.toBundle());
|
|
418
|
+
launchIntent.putExtra(
|
|
419
|
+
NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY,
|
|
420
|
+
InitialNotificationLedger.issueFullScreenLaunchId());
|
|
418
421
|
EventBus.postSticky(
|
|
419
422
|
new MainComponentEvent(fullScreenActionBundle.getMainComponent()));
|
|
420
423
|
}
|
|
421
424
|
|
|
422
|
-
PendingIntent fullScreenPendingIntent
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
425
|
+
PendingIntent fullScreenPendingIntent;
|
|
426
|
+
if (launchIntent.hasExtra(
|
|
427
|
+
NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY)) {
|
|
428
|
+
// A sole Activity target must run before the consumer is started. In a
|
|
429
|
+
// getActivities array, an intermediate, hidden Activity can stay
|
|
430
|
+
// uncreated.
|
|
431
|
+
Intent receiverIntent =
|
|
432
|
+
new Intent(
|
|
433
|
+
getApplicationContext(), FullScreenNotificationActivity.class);
|
|
434
|
+
receiverIntent.setAction(FullScreenNotificationActivity.ACTION);
|
|
435
|
+
// Preserve the component partition of the original consumer
|
|
436
|
+
// PendingIntent.
|
|
437
|
+
// Payload, flags and mainComponent remain updateable extras of that
|
|
438
|
+
// identity.
|
|
439
|
+
receiverIntent.setData(
|
|
440
|
+
android.net.Uri.fromParts(
|
|
441
|
+
"notifee-full-screen", launchActivityClass.getName(), null));
|
|
442
|
+
receiverIntent.putExtras(launchIntent);
|
|
443
|
+
// An unfinished NoDisplay root can lose a queued new Intent when it
|
|
444
|
+
// finishes in onCreate. Give each capture its own short-lived task.
|
|
445
|
+
receiverIntent.setFlags(
|
|
446
|
+
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
|
|
447
|
+
// Match the effective NEW_TASK flag of the original getActivity flow.
|
|
448
|
+
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
449
|
+
receiverIntent.putExtra(
|
|
450
|
+
FullScreenNotificationActivity.LAUNCH_INTENT,
|
|
451
|
+
new Intent(launchIntent).replaceExtras((Bundle) null));
|
|
452
|
+
fullScreenPendingIntent =
|
|
453
|
+
PendingIntent.getActivity(
|
|
454
|
+
getApplicationContext(),
|
|
455
|
+
notificationModel.getHashCode(),
|
|
456
|
+
receiverIntent,
|
|
457
|
+
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
|
458
|
+
} else {
|
|
459
|
+
fullScreenPendingIntent =
|
|
460
|
+
PendingIntent.getActivity(
|
|
461
|
+
getApplicationContext(),
|
|
462
|
+
notificationModel.getHashCode(),
|
|
463
|
+
launchIntent,
|
|
464
|
+
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
|
465
|
+
}
|
|
428
466
|
builder.setFullScreenIntent(fullScreenPendingIntent, true);
|
|
429
467
|
}
|
|
430
468
|
|
|
@@ -662,6 +700,12 @@ class NotificationManager {
|
|
|
662
700
|
|
|
663
701
|
static ListenableFuture<Void> displayNotification(
|
|
664
702
|
NotificationModel notificationModel, Bundle triggerBundle) {
|
|
703
|
+
// Reject invalid native/bridge opt-in before dispatching any START or posting a notification.
|
|
704
|
+
try {
|
|
705
|
+
notificationModel.getAndroid().getForegroundServiceBestEffortStopAfterMs();
|
|
706
|
+
} catch (RuntimeException invalidOption) {
|
|
707
|
+
return Futures.immediateFailedFuture(invalidOption);
|
|
708
|
+
}
|
|
665
709
|
return new ExtendedListenableFuture<>(notificationBundleToBuilder(notificationModel))
|
|
666
710
|
.continueWith(
|
|
667
711
|
(taskResult) -> {
|
|
@@ -29,6 +29,14 @@ import java.util.UUID;
|
|
|
29
29
|
public class NotificationPendingIntent {
|
|
30
30
|
public static final String EVENT_TYPE_INTENT_KEY = "notifee_event_type";
|
|
31
31
|
public static final String NOTIFICATION_ID_INTENT_KEY = "notification_id";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Random id shared by the Intents of one PendingIntent, or issued for a full-screen Intent, so a
|
|
35
|
+
* launch Intent can be matched with the interactions recorded by {@link
|
|
36
|
+
* InitialNotificationLedger}.
|
|
37
|
+
*/
|
|
38
|
+
static final String PENDING_INTENT_ID_INTENT_KEY = "notifee_pending_intent_id";
|
|
39
|
+
|
|
32
40
|
private static final String TAG = "NotificationPendingIntent";
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -74,6 +82,12 @@ public class NotificationPendingIntent {
|
|
|
74
82
|
setIntentExtras(launchActivityIntent, eventType, notificationId, extraKeys, extraBundles);
|
|
75
83
|
setIntentExtras(receiverIntent, eventType, notificationId, extraKeys, extraBundles);
|
|
76
84
|
|
|
85
|
+
String pendingIntentId = UUID.randomUUID().toString();
|
|
86
|
+
if (launchActivityIntent != null) {
|
|
87
|
+
launchActivityIntent.putExtra(PENDING_INTENT_ID_INTENT_KEY, pendingIntentId);
|
|
88
|
+
}
|
|
89
|
+
receiverIntent.putExtra(PENDING_INTENT_ID_INTENT_KEY, pendingIntentId);
|
|
90
|
+
|
|
77
91
|
// Create pending intent with activities
|
|
78
92
|
int uniqueInt = UUID.randomUUID().hashCode();
|
|
79
93
|
Intent[] intents;
|
|
@@ -19,6 +19,7 @@ package app.notifee.core;
|
|
|
19
19
|
|
|
20
20
|
import static app.notifee.core.NotificationPendingIntent.EVENT_TYPE_INTENT_KEY;
|
|
21
21
|
import static app.notifee.core.NotificationPendingIntent.NOTIFICATION_ID_INTENT_KEY;
|
|
22
|
+
import static app.notifee.core.NotificationPendingIntent.PENDING_INTENT_ID_INTENT_KEY;
|
|
22
23
|
import static app.notifee.core.event.NotificationEvent.TYPE_ACTION_PRESS;
|
|
23
24
|
import static app.notifee.core.event.NotificationEvent.TYPE_PRESS;
|
|
24
25
|
|
|
@@ -98,7 +99,11 @@ public class NotificationReceiverHandler {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
InitialNotificationEvent initialNotificationEvent =
|
|
101
|
-
new InitialNotificationEvent(
|
|
102
|
+
new InitialNotificationEvent(
|
|
103
|
+
notificationModel,
|
|
104
|
+
extras,
|
|
105
|
+
InitialNotificationLedger.recordTap(
|
|
106
|
+
intent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY)));
|
|
102
107
|
EventBus.postSticky(initialNotificationEvent);
|
|
103
108
|
|
|
104
109
|
// Send event
|
|
@@ -127,7 +132,11 @@ public class NotificationReceiverHandler {
|
|
|
127
132
|
EventBus.post(new NotificationEvent(TYPE_PRESS, notificationModel, extras));
|
|
128
133
|
|
|
129
134
|
InitialNotificationEvent initialNotificationEvent =
|
|
130
|
-
new InitialNotificationEvent(
|
|
135
|
+
new InitialNotificationEvent(
|
|
136
|
+
notificationModel,
|
|
137
|
+
extras,
|
|
138
|
+
InitialNotificationLedger.recordTap(
|
|
139
|
+
intent.getStringExtra(PENDING_INTENT_ID_INTENT_KEY)));
|
|
131
140
|
EventBus.postSticky(initialNotificationEvent);
|
|
132
141
|
|
|
133
142
|
if (pressActionBundle == null) {
|
|
@@ -26,10 +26,21 @@ public class InitialNotificationEvent {
|
|
|
26
26
|
|
|
27
27
|
private NotificationModel notificationModel;
|
|
28
28
|
private Bundle extras;
|
|
29
|
+
private long tapSequence;
|
|
29
30
|
|
|
30
31
|
public InitialNotificationEvent(NotificationModel notificationModel, Bundle extras) {
|
|
32
|
+
this(notificationModel, extras, 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param tapSequence sequence number recorded for the tap that produced this event, or 0 when the
|
|
37
|
+
* tap is not tracked.
|
|
38
|
+
*/
|
|
39
|
+
public InitialNotificationEvent(
|
|
40
|
+
NotificationModel notificationModel, Bundle extras, long tapSequence) {
|
|
31
41
|
this.notificationModel = notificationModel;
|
|
32
42
|
this.extras = extras;
|
|
43
|
+
this.tapSequence = tapSequence;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
public NotificationModel getNotificationModel() {
|
|
@@ -40,6 +51,10 @@ public class InitialNotificationEvent {
|
|
|
40
51
|
return extras;
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
public long getTapSequence() {
|
|
55
|
+
return tapSequence;
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
public Map<String, Object> toMap() {
|
|
44
59
|
Map<String, Object> initialNotificationEventMap = new HashMap<>();
|
|
45
60
|
initialNotificationEventMap.put("notificationModel", notificationModel.toBundle());
|
|
@@ -41,6 +41,8 @@ public class NotificationEvent {
|
|
|
41
41
|
|
|
42
42
|
@KeepForSdk public static final int TYPE_FG_TIMEOUT = 9;
|
|
43
43
|
|
|
44
|
+
@KeepForSdk public static final int TYPE_FG_RUN_LIMIT_REACHED = 10;
|
|
45
|
+
|
|
44
46
|
private final int type;
|
|
45
47
|
private final Bundle extras;
|
|
46
48
|
private final NotificationModel notification;
|
|
@@ -85,6 +87,10 @@ public class NotificationEvent {
|
|
|
85
87
|
eventDetailMap.put("input", input);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
Bundle runLimit = extras.getBundle("foregroundServiceRunLimit");
|
|
91
|
+
if (runLimit != null)
|
|
92
|
+
eventDetailMap.put("foregroundServiceRunLimit", ObjectUtils.bundleToMap(runLimit));
|
|
93
|
+
|
|
88
94
|
if (extras.containsKey("startId")) {
|
|
89
95
|
eventDetailMap.put("startId", extras.getInt("startId"));
|
|
90
96
|
}
|