react-native-notify-kit 9.5.0 → 9.7.0

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.
Files changed (25) hide show
  1. package/README.md +204 -2
  2. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +378 -13
  3. package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
  4. package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
  5. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +228 -17
  6. package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
  7. package/android/src/main/java/app/notifee/core/NotificationManager.java +98 -0
  8. package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
  9. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
  10. package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
  11. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
  12. package/android/src/test/java/app/notifee/core/GetDisplayedNotificationsDataTest.java +242 -0
  13. package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
  14. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
  15. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
  16. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
  17. package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
  18. package/dist/types/Notification.d.ts +35 -4
  19. package/dist/types/Notification.js.map +1 -1
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/jest-mock.js +1 -0
  23. package/package.json +1 -1
  24. package/src/types/Notification.ts +35 -4
  25. package/src/version.ts +1 -1
@@ -0,0 +1,242 @@
1
+ package app.notifee.core;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import static org.junit.Assert.assertNotNull;
20
+ import static org.junit.Assert.assertSame;
21
+ import static org.junit.Assert.fail;
22
+ import static org.mockito.ArgumentMatchers.any;
23
+ import static org.mockito.ArgumentMatchers.anyString;
24
+ import static org.mockito.Mockito.mock;
25
+ import static org.mockito.Mockito.mockStatic;
26
+ import static org.mockito.Mockito.never;
27
+ import static org.mockito.Mockito.times;
28
+ import static org.mockito.Mockito.verify;
29
+ import static org.mockito.Mockito.when;
30
+
31
+ import android.os.Bundle;
32
+ import app.notifee.core.database.WorkDataRepository;
33
+ import app.notifee.core.model.NotificationModel;
34
+ import app.notifee.core.model.TimestampTriggerModel;
35
+ import com.google.common.util.concurrent.Futures;
36
+ import com.google.common.util.concurrent.ListenableFuture;
37
+ import com.google.common.util.concurrent.MoreExecutors;
38
+ import java.util.concurrent.ExecutionException;
39
+ import java.util.concurrent.Executor;
40
+ import java.util.concurrent.TimeUnit;
41
+ import org.junit.After;
42
+ import org.junit.Before;
43
+ import org.junit.Test;
44
+ import org.junit.runner.RunWith;
45
+ import org.mockito.MockedStatic;
46
+ import org.robolectric.RobolectricTestRunner;
47
+ import org.robolectric.RuntimeEnvironment;
48
+
49
+ /**
50
+ * Regression guards for the {@code NotifeeAlarmManager.handleStaleNonRepeatingTrigger} resilience
51
+ * chain introduced for upstream invertase/notifee#734 and hardened in Step 7 after a post-Step-6
52
+ * code review flagged two latent holes:
53
+ *
54
+ * <ol>
55
+ * <li><b>HIGH #1</b> — {@code Futures.catchingAsync(..., Throwable.class, ...)} was swallowing
56
+ * {@link Error} subclasses including {@link OutOfMemoryError}. On a memory-pressured cold
57
+ * boot (the exact target scenario of #734) this would mask a JVM-level failure and cause the
58
+ * handler to proceed to a Room write seconds before process termination. Narrowed to {@link
59
+ * Exception} so Errors propagate as batch failures.
60
+ * <li><b>MEDIUM #1</b> — if {@code NotificationManager.displayNotification} threw a synchronous
61
+ * exception before returning its {@link ListenableFuture} (e.g. NPE from {@code
62
+ * NotificationModel.getAndroid()} upstream of the work Callable), the throw would bypass
63
+ * {@code catchingAsync} entirely because the primary input future did not yet exist. Wrapping
64
+ * the call in {@link Futures#submitAsync} converts any sync throw from the {@code
65
+ * AsyncCallable} into a failed future the catching branch can observe.
66
+ * </ol>
67
+ *
68
+ * <p>Test strategy: {@link MockedStatic} intercepts both {@code NotificationManager} and {@code
69
+ * WorkDataRepository} static calls so the helper can be exercised in isolation without a real
70
+ * Android notification channel, a real notification post, or a real Room database. Each test stubs
71
+ * {@code displayNotification} with a specific failure shape and asserts whether {@code deleteById}
72
+ * is invoked.
73
+ *
74
+ * <p>Mockito 5.x's default {@link org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker}
75
+ * makes {@code mockStatic} available without an explicit {@code mockito-inline} dependency — the
76
+ * fork already uses {@code mockito-core:5.19.0} per {@code build.gradle:154}.
77
+ */
78
+ @RunWith(RobolectricTestRunner.class)
79
+ public class NotifeeAlarmManagerHandleStaleTest {
80
+
81
+ /** 1 hour in the past — well inside the 24h STALE_TRIGGER_GRACE_PERIOD_MS window. */
82
+ private static final long STALE_WITHIN_GRACE_OFFSET_MS = 60L * 60 * 1000;
83
+
84
+ /**
85
+ * Same-thread executor. Required because Mockito {@link MockedStatic} stubs are thread-local to
86
+ * the thread that activated them — if the production code dispatched the {@code
87
+ * submitAsync}/{@code catchingAsync}/{@code transformAsync} chain to a worker thread pool, the
88
+ * real {@code NotificationManager.displayNotification} would be called instead of the stub,
89
+ * masking the exact regression this test is guarding against. Passing the directExecutor into the
90
+ * {@code handleStaleNonRepeatingTrigger(..., Executor)} overload keeps the whole chain on the
91
+ * test thread where the stubs are active.
92
+ */
93
+ private static final Executor DIRECT_EXECUTOR = MoreExecutors.directExecutor();
94
+
95
+ private MockedStatic<NotificationManager> notificationManagerMock;
96
+ private MockedStatic<WorkDataRepository> workDataRepositoryMock;
97
+ private WorkDataRepository mockRepo;
98
+
99
+ @Before
100
+ public void setUp() {
101
+ // ContextHolder is consulted inside handleStaleNonRepeatingTrigger via
102
+ // WorkDataRepository.getInstance(getApplicationContext()) — populate with Robolectric's
103
+ // application context so the static call does not NPE before our mock intercepts it.
104
+ ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
105
+
106
+ mockRepo = mock(WorkDataRepository.class);
107
+ // deleteById is the terminal step of the resilient chain. Default stub returns an immediate
108
+ // success so the outer future completes cleanly; individual tests can override.
109
+ when(mockRepo.deleteById(anyString())).thenReturn(Futures.immediateFuture(null));
110
+
111
+ workDataRepositoryMock = mockStatic(WorkDataRepository.class);
112
+ workDataRepositoryMock.when(() -> WorkDataRepository.getInstance(any())).thenReturn(mockRepo);
113
+
114
+ notificationManagerMock = mockStatic(NotificationManager.class);
115
+ }
116
+
117
+ @After
118
+ public void tearDown() {
119
+ if (notificationManagerMock != null) {
120
+ notificationManagerMock.close();
121
+ }
122
+ if (workDataRepositoryMock != null) {
123
+ workDataRepositoryMock.close();
124
+ }
125
+ }
126
+
127
+ // ─── Test 3.1: Exception in display → row deleted (happy-resilience path) ───
128
+
129
+ @Test
130
+ public void handleStaleNonRepeatingTrigger_displayFailsWithRuntimeException_rowIsDeleted()
131
+ throws Exception {
132
+ notificationManagerMock
133
+ .when(() -> NotificationManager.displayNotification(any(), any()))
134
+ .thenReturn(Futures.immediateFailedFuture(new RuntimeException("synthetic display fail")));
135
+
136
+ NotificationModel model = buildStaleModel("test-runtime-exception");
137
+ TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
138
+
139
+ ListenableFuture<Void> result =
140
+ NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
141
+
142
+ assertNotNull("helper must return a non-null future for a stale trigger", result);
143
+ // Must complete without throwing — catchingAsync swallows the Exception and proceeds.
144
+ result.get(5, TimeUnit.SECONDS);
145
+ verify(mockRepo, times(1)).deleteById("test-runtime-exception");
146
+ }
147
+
148
+ // ─── Test 3.2: Error in display → row NOT deleted, Error propagates ──────────
149
+ // This is the HIGH #1 regression guard. Before the fix, Throwable.class
150
+ // would have caught the OOM and proceeded to deleteById. After the fix,
151
+ // Exception.class lets Errors propagate so the per-entity catch in
152
+ // rescheduleNotifications leaves the row in Room for a real retry.
153
+
154
+ @Test
155
+ public void handleStaleNonRepeatingTrigger_displayFailsWithError_rowIsNotDeleted_errorPropagates()
156
+ throws Exception {
157
+ OutOfMemoryError oom = new OutOfMemoryError("synthetic OOM");
158
+ notificationManagerMock
159
+ .when(() -> NotificationManager.displayNotification(any(), any()))
160
+ .thenReturn(Futures.immediateFailedFuture(oom));
161
+
162
+ NotificationModel model = buildStaleModel("test-oom");
163
+ TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
164
+
165
+ ListenableFuture<Void> result =
166
+ NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
167
+
168
+ assertNotNull("helper must return a non-null future for a stale trigger", result);
169
+
170
+ ExecutionException thrown = null;
171
+ try {
172
+ result.get(5, TimeUnit.SECONDS);
173
+ fail("Error must propagate as ExecutionException — not be swallowed by catchingAsync");
174
+ } catch (ExecutionException e) {
175
+ thrown = e;
176
+ }
177
+ assertNotNull(thrown);
178
+ assertSame(
179
+ "underlying cause must be the original OutOfMemoryError, not a wrapped RuntimeException",
180
+ oom,
181
+ thrown.getCause());
182
+
183
+ verify(mockRepo, never()).deleteById(anyString());
184
+ }
185
+
186
+ // ─── Test 3.3: Sync throw in display → row deleted via submitAsync wrap ─────
187
+ // This is the MEDIUM #1 regression guard. Before the fix, a sync throw
188
+ // upstream of the Callable would bypass catchingAsync because the primary
189
+ // future never existed. After the fix, Futures.submitAsync intercepts the
190
+ // sync throw and converts it into a failed future catchingAsync can catch.
191
+
192
+ @Test
193
+ public void handleStaleNonRepeatingTrigger_syncThrowInDisplay_rowIsDeleted_viaSubmitAsyncWrap()
194
+ throws Exception {
195
+ notificationManagerMock
196
+ .when(() -> NotificationManager.displayNotification(any(), any()))
197
+ .thenThrow(new IllegalStateException("synthetic sync throw before future creation"));
198
+
199
+ NotificationModel model = buildStaleModel("test-sync-throw");
200
+ TimestampTriggerModel trigger = buildStaleWithinGraceTrigger();
201
+
202
+ ListenableFuture<Void> result =
203
+ NotifeeAlarmManager.handleStaleNonRepeatingTrigger(model, trigger, DIRECT_EXECUTOR);
204
+
205
+ assertNotNull("helper must return a non-null future for a stale trigger", result);
206
+ // Must complete without throwing — submitAsync converts the sync throw into a failed
207
+ // future, catchingAsync intercepts, and the chain proceeds to deleteById.
208
+ result.get(5, TimeUnit.SECONDS);
209
+ verify(mockRepo, times(1)).deleteById("test-sync-throw");
210
+ }
211
+
212
+ // ─── Builders ──────────────────────────────────────────────────────────────
213
+
214
+ private static NotificationModel buildStaleModel(String id) {
215
+ Bundle notificationBundle = new Bundle();
216
+ notificationBundle.putString("id", id);
217
+ notificationBundle.putString("title", "HandleStaleTest " + id);
218
+
219
+ // A well-formed android sub-bundle is included so the path that the mock intercepts never
220
+ // touches a malformed NotificationModel — the test is exclusively about the resilient chain
221
+ // in handleStaleNonRepeatingTrigger, not about NotificationModel shape validation.
222
+ Bundle androidBundle = new Bundle();
223
+ androidBundle.putString("channelId", "handle-stale-test-channel");
224
+ notificationBundle.putBundle("android", androidBundle);
225
+
226
+ return NotificationModel.fromBundle(notificationBundle);
227
+ }
228
+
229
+ private static TimestampTriggerModel buildStaleWithinGraceTrigger() {
230
+ long anchorMs = System.currentTimeMillis() - STALE_WITHIN_GRACE_OFFSET_MS;
231
+
232
+ Bundle triggerBundle = new Bundle();
233
+ triggerBundle.putInt("type", 0); // TIMESTAMP
234
+ triggerBundle.putLong("timestamp", anchorMs);
235
+ triggerBundle.putInt("repeatFrequency", -1); // non-repeating
236
+ Bundle alarmManagerBundle = new Bundle();
237
+ alarmManagerBundle.putInt("type", 3); // SET_EXACT_AND_ALLOW_WHILE_IDLE
238
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
239
+
240
+ return TimestampTriggerModel.fromBundle(triggerBundle);
241
+ }
242
+ }
@@ -0,0 +1,264 @@
1
+ package app.notifee.core;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ import static org.junit.Assert.assertEquals;
20
+ import static org.junit.Assert.assertNotNull;
21
+ import static org.mockito.ArgumentMatchers.any;
22
+ import static org.mockito.ArgumentMatchers.anyInt;
23
+ import static org.mockito.ArgumentMatchers.anyLong;
24
+ import static org.mockito.ArgumentMatchers.eq;
25
+ import static org.mockito.Mockito.doThrow;
26
+ import static org.mockito.Mockito.mock;
27
+ import static org.mockito.Mockito.mockStatic;
28
+ import static org.mockito.Mockito.never;
29
+ import static org.mockito.Mockito.times;
30
+ import static org.mockito.Mockito.verify;
31
+ import static org.mockito.Mockito.when;
32
+
33
+ import android.app.AlarmManager;
34
+ import android.app.PendingIntent;
35
+ import android.os.Bundle;
36
+ import app.notifee.core.model.NotificationModel;
37
+ import app.notifee.core.model.TimestampTriggerModel;
38
+ import app.notifee.core.utility.AlarmUtils;
39
+ import org.junit.After;
40
+ import org.junit.Before;
41
+ import org.junit.Test;
42
+ import org.junit.runner.RunWith;
43
+ import org.mockito.ArgumentCaptor;
44
+ import org.mockito.MockedStatic;
45
+ import org.robolectric.RobolectricTestRunner;
46
+ import org.robolectric.RuntimeEnvironment;
47
+ import org.robolectric.annotation.Config;
48
+
49
+ /**
50
+ * Unit coverage for the {@link TimestampTriggerModel.AlarmType#SET_ALARM_CLOCK} branch of {@link
51
+ * NotifeeAlarmManager#scheduleTimestampTriggerNotification} and its supporting helper {@link
52
+ * NotifeeAlarmManager#buildShowIntentPressActionBundle}. This branch was added in upstream {@code
53
+ * invertase/notifee#749} (closing {@code #655}) and refactored in this fork to reuse the {@code
54
+ * pressAction} path that the 9.1.19 / 9.3.0 fixes rely on — see the commit that introduces this
55
+ * file for the full rationale.
56
+ *
57
+ * <p>Three scenarios are guarded:
58
+ *
59
+ * <ol>
60
+ * <li><b>Happy path</b> — a non-repeating TIMESTAMP trigger with {@code
61
+ * AlarmType.SET_ALARM_CLOCK} results in exactly one {@link
62
+ * AlarmManager#setAlarmClock(AlarmManager.AlarmClockInfo, PendingIntent)} call, the captured
63
+ * {@link AlarmManager.AlarmClockInfo} carries a non-null show intent, and the inexact
64
+ * fallback path is not exercised.
65
+ * <li><b>SecurityException fallback</b> — if {@code setAlarmClock} throws (Android 12+ can reject
66
+ * even after {@code canScheduleExactAlarms()} returns true, e.g. when the permission is
67
+ * revoked between the pre-check and the schedule call), the catch in {@code
68
+ * scheduleTimestampTriggerNotification} must route to {@code setAndAllowWhileIdle} so the
69
+ * notification still fires as an inexact alarm rather than disappearing silently.
70
+ * <li><b>Show-intent pressAction resolution</b> — a regression guard for the refactor that
71
+ * replaced the ad-hoc {@code getLaunchIntentForPackage} with a call into {@link
72
+ * NotificationPendingIntent#createIntent}. The helper must (a) synthesize the {@code {
73
+ * id:'default', launchActivity:'default' }} bundle when the notification has no {@code
74
+ * pressAction} (typical for triggers rehydrated from Room after an app kill), (b) synthesize
75
+ * the same default when the user opted out via {@link
76
+ * NotificationPendingIntent#PRESS_ACTION_OPT_OUT_ID} (the alarm-clock icon in the status bar
77
+ * has no non-tappable mode, so we still need a valid show intent), and (c) pass a custom
78
+ * pressAction through unchanged so {@code launchActivity} / {@code mainComponent} routing is
79
+ * honoured end-to-end.
80
+ * </ol>
81
+ *
82
+ * <p>Test strategy: {@link MockedStatic} intercepts {@link AlarmUtils#getAlarmManager()} so the
83
+ * production code receives a Mockito-controlled {@link AlarmManager} instead of Robolectric's
84
+ * shadow. This lets the tests stub {@code canScheduleExactAlarms()} to skip the Android S+
85
+ * pre-check, stub {@code setAlarmClock} to either succeed or throw, and capture the {@link
86
+ * AlarmManager.AlarmClockInfo} that reached the platform. Everything else — {@link ContextHolder},
87
+ * {@link NotificationPendingIntent#createIntent}, the Intent/PendingIntent plumbing — runs against
88
+ * Robolectric's real application context.
89
+ */
90
+ @RunWith(RobolectricTestRunner.class)
91
+ // Robolectric's default SDK is too low for AlarmManager#canScheduleExactAlarms() (API 31+).
92
+ // Pin to API 34 — matches ForegroundServiceTest and exercises the Android S+ pre-check branch
93
+ // that the production code in scheduleTimestampTriggerNotification guards against.
94
+ @Config(sdk = 34)
95
+ public class NotifeeAlarmManagerSetAlarmClockTest {
96
+
97
+ /** A trigger timestamp comfortably in the future so {@code setNextTimestamp} is a no-op. */
98
+ private static final long FUTURE_OFFSET_MS = 60_000L;
99
+
100
+ private MockedStatic<AlarmUtils> alarmUtilsMock;
101
+ private AlarmManager mockAlarmManager;
102
+
103
+ @Before
104
+ public void setUp() {
105
+ // NotificationPendingIntent.createIntent, getAlarmManagerIntentForNotification, and the
106
+ // trigger-rescheduling path all call ContextHolder.getApplicationContext(). Robolectric's
107
+ // application context exposes a real PackageManager so the launch-intent fallback inside
108
+ // NotificationPendingIntent.createLaunchActivityIntent succeeds without mocking.
109
+ ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
110
+
111
+ mockAlarmManager = mock(AlarmManager.class);
112
+ // Skip the Android S+ pre-check — we explicitly want the switch to reach the SET_ALARM_CLOCK
113
+ // branch. If canScheduleExactAlarms() returned Mockito's default (false), the pre-check would
114
+ // fall back to setAndAllowWhileIdle before the switch and the tests would never exercise the
115
+ // production path under scrutiny.
116
+ when(mockAlarmManager.canScheduleExactAlarms()).thenReturn(true);
117
+
118
+ alarmUtilsMock = mockStatic(AlarmUtils.class);
119
+ alarmUtilsMock.when(AlarmUtils::getAlarmManager).thenReturn(mockAlarmManager);
120
+ }
121
+
122
+ @After
123
+ public void tearDown() {
124
+ if (alarmUtilsMock != null) {
125
+ alarmUtilsMock.close();
126
+ }
127
+ }
128
+
129
+ // ─── Test 1: Happy path — setAlarmClock called with a non-null show intent ──
130
+
131
+ @Test
132
+ public void setAlarmClock_happyPath_usesAlarmClockInfoWithShowIntent() {
133
+ NotificationModel model = buildModel("happy-path");
134
+ TimestampTriggerModel trigger = buildSetAlarmClockTrigger();
135
+
136
+ NotifeeAlarmManager.scheduleTimestampTriggerNotification(model, trigger);
137
+
138
+ ArgumentCaptor<AlarmManager.AlarmClockInfo> infoCaptor =
139
+ ArgumentCaptor.forClass(AlarmManager.AlarmClockInfo.class);
140
+ ArgumentCaptor<PendingIntent> operationCaptor = ArgumentCaptor.forClass(PendingIntent.class);
141
+ verify(mockAlarmManager, times(1))
142
+ .setAlarmClock(infoCaptor.capture(), operationCaptor.capture());
143
+
144
+ AlarmManager.AlarmClockInfo info = infoCaptor.getValue();
145
+ assertNotNull("AlarmClockInfo must be non-null", info);
146
+ assertNotNull(
147
+ "AlarmClockInfo.showIntent must be non-null — AlarmManager requires a tap target for"
148
+ + " the status-bar alarm-clock icon",
149
+ info.getShowIntent());
150
+ assertNotNull(
151
+ "operation PendingIntent (alarm fire target) must be non-null", operationCaptor.getValue());
152
+
153
+ // The happy path must never touch the inexact fallback.
154
+ verify(mockAlarmManager, never())
155
+ .setAndAllowWhileIdle(anyInt(), anyLong(), any(PendingIntent.class));
156
+ }
157
+
158
+ // ─── Test 2: SecurityException → fallback to setAndAllowWhileIdle ──────────
159
+
160
+ @Test
161
+ public void setAlarmClock_securityException_fallsBackToInexact() {
162
+ // canScheduleExactAlarms() can race with permission revocation on Android 12+: the pre-check
163
+ // passes, but the subsequent setAlarmClock call still throws. The production code's
164
+ // try/catch inside scheduleTimestampTriggerNotification must route that throw to
165
+ // setAndAllowWhileIdle so the notification degrades gracefully instead of disappearing.
166
+ doThrow(new SecurityException("synthetic SCHEDULE_EXACT_ALARM denied at fire time"))
167
+ .when(mockAlarmManager)
168
+ .setAlarmClock(any(AlarmManager.AlarmClockInfo.class), any(PendingIntent.class));
169
+
170
+ NotificationModel model = buildModel("security-exception");
171
+ TimestampTriggerModel trigger = buildSetAlarmClockTrigger();
172
+
173
+ NotifeeAlarmManager.scheduleTimestampTriggerNotification(model, trigger);
174
+
175
+ // Primary attempt happened.
176
+ verify(mockAlarmManager, times(1))
177
+ .setAlarmClock(any(AlarmManager.AlarmClockInfo.class), any(PendingIntent.class));
178
+ // Fallback fired with RTC_WAKEUP — matches the other AlarmType branches' wake semantics.
179
+ verify(mockAlarmManager, times(1))
180
+ .setAndAllowWhileIdle(eq(AlarmManager.RTC_WAKEUP), anyLong(), any(PendingIntent.class));
181
+ }
182
+
183
+ // ─── Test 3: show-intent reuses the pressAction path (refactor guard) ─────
184
+
185
+ @Test
186
+ public void setAlarmClock_showIntentReusesPressActionPath() {
187
+ // Case 1: notification has no pressAction at all (e.g. rehydrated from Room after app kill).
188
+ // buildShowIntentPressActionBundle must synthesize the default so the status-bar icon opens
189
+ // the app via the same route as a normal tap.
190
+ Bundle absent = NotifeeAlarmManager.buildShowIntentPressActionBundle(buildModel("absent"));
191
+ assertNotNull("default pressAction must be synthesized when absent", absent);
192
+ assertEquals("default", absent.getString("id"));
193
+ assertEquals("default", absent.getString("launchActivity"));
194
+
195
+ // Case 2: notification was built with pressAction:null in JS, which surfaces in the native
196
+ // layer as PRESS_ACTION_OPT_OUT_ID. Unlike the content intent (where null means "non-tappable
197
+ // notification"), AlarmClockInfo demands a non-null show intent, so the helper must still
198
+ // synthesize the default instead of returning null and crashing the schedule call.
199
+ Bundle optOut =
200
+ NotifeeAlarmManager.buildShowIntentPressActionBundle(
201
+ buildModelWithPressAction(
202
+ "opt-out", NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID, null));
203
+ assertNotNull("default pressAction must be synthesized on opt-out sentinel", optOut);
204
+ assertEquals("default", optOut.getString("id"));
205
+ assertEquals("default", optOut.getString("launchActivity"));
206
+
207
+ // Case 3: a real custom pressAction must pass through untouched so custom launchActivity
208
+ // routing reaches NotificationPendingIntent.createLaunchActivityIntent.
209
+ Bundle custom =
210
+ NotifeeAlarmManager.buildShowIntentPressActionBundle(
211
+ buildModelWithPressAction("custom", "my-action", "com.example.CustomActivity"));
212
+ assertNotNull("custom pressAction must pass through", custom);
213
+ assertEquals("my-action", custom.getString("id"));
214
+ assertEquals("com.example.CustomActivity", custom.getString("launchActivity"));
215
+ }
216
+
217
+ // ─── Builders ──────────────────────────────────────────────────────────────
218
+
219
+ private static NotificationModel buildModel(String id) {
220
+ Bundle notificationBundle = new Bundle();
221
+ notificationBundle.putString("id", id);
222
+ notificationBundle.putString("title", "SetAlarmClockTest " + id);
223
+
224
+ Bundle androidBundle = new Bundle();
225
+ androidBundle.putString("channelId", "set-alarm-clock-test-channel");
226
+ notificationBundle.putBundle("android", androidBundle);
227
+
228
+ return NotificationModel.fromBundle(notificationBundle);
229
+ }
230
+
231
+ private static NotificationModel buildModelWithPressAction(
232
+ String id, String pressActionId, String launchActivity) {
233
+ Bundle notificationBundle = new Bundle();
234
+ notificationBundle.putString("id", id);
235
+ notificationBundle.putString("title", "SetAlarmClockTest " + id);
236
+
237
+ Bundle androidBundle = new Bundle();
238
+ androidBundle.putString("channelId", "set-alarm-clock-test-channel");
239
+
240
+ Bundle pressAction = new Bundle();
241
+ pressAction.putString("id", pressActionId);
242
+ if (launchActivity != null) {
243
+ pressAction.putString("launchActivity", launchActivity);
244
+ }
245
+ androidBundle.putBundle("pressAction", pressAction);
246
+
247
+ notificationBundle.putBundle("android", androidBundle);
248
+
249
+ return NotificationModel.fromBundle(notificationBundle);
250
+ }
251
+
252
+ private static TimestampTriggerModel buildSetAlarmClockTrigger() {
253
+ Bundle triggerBundle = new Bundle();
254
+ triggerBundle.putInt("type", 0); // TIMESTAMP
255
+ triggerBundle.putLong("timestamp", System.currentTimeMillis() + FUTURE_OFFSET_MS);
256
+ triggerBundle.putInt("repeatFrequency", -1); // non-repeating
257
+
258
+ Bundle alarmManagerBundle = new Bundle();
259
+ alarmManagerBundle.putInt("type", 4); // SET_ALARM_CLOCK (TimestampTriggerModel switch case 4)
260
+ triggerBundle.putBundle("alarmManager", alarmManagerBundle);
261
+
262
+ return TimestampTriggerModel.fromBundle(triggerBundle);
263
+ }
264
+ }