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 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
- InitialNotificationEvent event = EventBus.removeStickEvent(InitialNotificationEvent.class);
436
- Bundle initialNotificationBundle = new Bundle();
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
- if (event != null) {
439
- initialNotificationBundle.putAll(event.getExtras());
440
- initialNotificationBundle.putBundle("notification", event.getNotificationModel().toBundle());
441
- result.onComplete(null, initialNotificationBundle);
442
- return;
443
- } else if (activity != null) {
444
- try {
445
- // get intent from current activity
446
- Intent intent = activity.getIntent();
447
- if (intent != null && intent.getExtras() != null && intent.hasExtra("notification")) {
448
- initialNotificationBundle.putBundle(
449
- "notification", intent.getBundleExtra("notification"));
450
- result.onComplete(null, initialNotificationBundle);
451
- return;
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
- // If no initial notification, return
459
- result.onComplete(null, null);
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
- PendingIntent.getActivity(
424
- getApplicationContext(),
425
- notificationModel.getHashCode(),
426
- launchIntent,
427
- PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
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;