react-native-notify-kit 10.9.0 → 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 +2 -0
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/app/notifee/core/EventBus.java +4 -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 +44 -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/test/java/app/notifee/core/NotifeeGetInitialNotificationTest.java +3805 -0
- package/cli/package.json +1 -1
- package/dist/types/Module.d.ts +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/types/Module.ts +10 -3
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ This fork fills the gap: it preserves all of Notifee's advanced features, migrat
|
|
|
55
55
|
|
|
56
56
|
The native core (NotifeeCore) is compiled from source as part of the bridge module (since 9.2.0) and the public API is **100% compatible** with the original `@notifee/react-native` — migration is a safe, drop-in replacement.
|
|
57
57
|
|
|
58
|
+
**10.9.1 Android fixes — [#88](https://github.com/marcocrupi/react-native-notify-kit/issues/88):** `getInitialNotification()` no longer replays consumed identifiable interactions; new interactions, including taps reusing a notification ID, remain available. Supported recovery and native full-screen capture use the [documented one-shot and latest-wins semantics and limits](https://docs.page/marcocrupi/react-native-notify-kit/react-native/events#android-consumption-and-recovery). Related upstream [notifee#880](https://github.com/invertase/notifee/issues/880) is not claimed fully resolved by this qualification.
|
|
59
|
+
|
|
58
60
|
## Installation
|
|
59
61
|
|
|
60
62
|
```bash
|
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" android:minSdkVersion="23" />
|
|
13
13
|
|
|
14
14
|
<application>
|
|
15
|
+
<!-- Each capture finishes itself; noHistory can cancel a queued launch before onCreate. -->
|
|
16
|
+
<activity
|
|
17
|
+
android:name="app.notifee.core.FullScreenNotificationActivity"
|
|
18
|
+
android:noHistory="false"
|
|
19
|
+
android:excludeFromRecents="true"
|
|
20
|
+
android:taskAffinity=""
|
|
21
|
+
android:theme="@android:style/Theme.NoDisplay"
|
|
22
|
+
android:showWhenLocked="true"
|
|
23
|
+
android:turnScreenOn="true"
|
|
24
|
+
android:exported="false" />
|
|
25
|
+
|
|
15
26
|
<!-- Receiver Service -->
|
|
16
27
|
<service
|
|
17
28
|
android:name="app.notifee.core.ReceiverService"
|
|
@@ -57,6 +57,10 @@ public class EventBus {
|
|
|
57
57
|
return getInstance().getDefault().removeStickyEvent(eventType);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
static boolean removeStickyEvent(Object event) {
|
|
61
|
+
return getInstance().getDefault().removeStickyEvent(event);
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
private org.greenrobot.eventbus.EventBus getDefault() {
|
|
61
65
|
return eventBus;
|
|
62
66
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2016-present Invertase Limited & Contributors
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this library except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import android.app.Activity;
|
|
21
|
+
import android.content.Intent;
|
|
22
|
+
import android.os.Bundle;
|
|
23
|
+
import android.view.WindowManager;
|
|
24
|
+
|
|
25
|
+
/** Captures a full-screen launch before starting its consumer, without displaying a window. */
|
|
26
|
+
public class FullScreenNotificationActivity extends Activity {
|
|
27
|
+
static final String ACTION = "app.notifee.core.FULL_SCREEN_LAUNCH";
|
|
28
|
+
static final String LAUNCH_INTENT = "app.notifee.core.FULL_SCREEN_CONSUMER";
|
|
29
|
+
|
|
30
|
+
@Override
|
|
31
|
+
protected void onCreate(Bundle state) {
|
|
32
|
+
// API 26 uses window flags; newer versions also use the manifest's screen/keyguard flags.
|
|
33
|
+
getWindow()
|
|
34
|
+
.addFlags(
|
|
35
|
+
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
|
36
|
+
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
|
|
37
|
+
super.onCreate(state);
|
|
38
|
+
launchConsumer(getIntent());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Override
|
|
42
|
+
protected void onNewIntent(Intent intent) {
|
|
43
|
+
super.onNewIntent(intent);
|
|
44
|
+
launchConsumer(intent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private void launchConsumer(Intent intent) {
|
|
48
|
+
try {
|
|
49
|
+
if (intent == null
|
|
50
|
+
|| !ACTION.equals(intent.getAction())
|
|
51
|
+
|| !Notifee.isValidFullScreenLaunch(intent)) return;
|
|
52
|
+
Intent destination = intent.getParcelableExtra(LAUNCH_INTENT);
|
|
53
|
+
if (destination == null
|
|
54
|
+
|| destination.getComponent() == null
|
|
55
|
+
|| !getPackageName().equals(destination.getComponent().getPackageName())) return;
|
|
56
|
+
// An invalid/disabled destination must not retire a previous unread launch.
|
|
57
|
+
if (getPackageManager().resolveActivity(destination, 0) == null) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// The carrier owns the payload. The nested Intent contains only the original destination
|
|
61
|
+
// and flags, so the private routing extra never reaches the consumer or the public API.
|
|
62
|
+
Intent consumer = new Intent(destination);
|
|
63
|
+
consumer.replaceExtras(new Bundle(intent.getExtras()));
|
|
64
|
+
consumer.removeExtra(LAUNCH_INTENT);
|
|
65
|
+
if (ContextHolder.getApplicationContext() == null) {
|
|
66
|
+
ContextHolder.setApplicationContext(getApplicationContext());
|
|
67
|
+
}
|
|
68
|
+
Notifee.recordFullScreenLaunch(consumer);
|
|
69
|
+
startActivity(consumer);
|
|
70
|
+
} catch (Exception e) {
|
|
71
|
+
Logger.e("FullScreenNotificationActivity", "Failed to launch full-screen consumer", e);
|
|
72
|
+
} finally {
|
|
73
|
+
finish();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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
|
|
|
@@ -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;
|