react-native-notify-kit 9.5.0 → 9.6.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.
@@ -1,11 +1,26 @@
1
1
  package app.notifee.core;
2
2
 
3
3
  import static org.junit.Assert.assertEquals;
4
+ import static org.junit.Assert.assertNotNull;
5
+ import static org.junit.Assert.assertNull;
4
6
  import static org.junit.Assert.assertTrue;
5
7
  import static org.junit.Assert.fail;
6
8
 
9
+ import android.app.Notification;
10
+ import android.app.NotificationChannel;
11
+ import android.app.NotificationManager;
7
12
  import android.app.Service;
13
+ import android.content.Context;
8
14
  import android.content.Intent;
15
+ import android.content.pm.ServiceInfo;
16
+ import android.os.Bundle;
17
+ import androidx.core.app.NotificationCompat;
18
+ import app.notifee.core.event.NotificationEvent;
19
+ import java.lang.reflect.Field;
20
+ import java.util.ArrayList;
21
+ import java.util.List;
22
+ import org.greenrobot.eventbus.Subscribe;
23
+ import org.greenrobot.eventbus.ThreadMode;
9
24
  import org.junit.After;
10
25
  import org.junit.Before;
11
26
  import org.junit.Test;
@@ -26,12 +41,15 @@ public class ForegroundServiceTest {
26
41
  }
27
42
 
28
43
  @After
29
- public void tearDown() {
30
- // Reset all accessible static fields to prevent cross-test pollution.
31
- // mCurrentNotificationBundle, mCurrentNotification, mCurrentHashCode are private static
32
- // and can only be reset via onStartCommand (the STOP path clears them).
44
+ public void tearDown() throws Exception {
45
+ // Reset public and private static fields to prevent cross-test pollution. The three private
46
+ // statics are cleared via reflection here so that tests which seed them directly (to exercise
47
+ // onTimeout without running the full START path) cannot leak state into neighbouring tests.
33
48
  ForegroundService.mCurrentNotificationId = null;
34
49
  ForegroundService.mCurrentForegroundServiceType = -1;
50
+ setPrivateStatic("mCurrentNotificationBundle", null);
51
+ setPrivateStatic("mCurrentNotification", null);
52
+ setPrivateStatic("mCurrentHashCode", 0);
35
53
  }
36
54
 
37
55
  /**
@@ -179,4 +197,233 @@ public class ForegroundServiceTest {
179
197
  int result = service.onStartCommand(stopIntent, 0, 2);
180
198
  assertEquals(Service.START_STICKY_COMPATIBILITY, result);
181
199
  }
200
+
201
+ // ──────────────────────────────────────────────────────────────────────────
202
+ // START path and onTimeout event emission (regression guards for 9.1.13)
203
+ // ──────────────────────────────────────────────────────────────────────────
204
+
205
+ private static final String TEST_CHANNEL_ID = "fgs-test-channel";
206
+
207
+ /**
208
+ * START happy path: a valid intent with a notification payload must drive the service through
209
+ * {@code startForeground()} and leave {@code mCurrentNotificationId} populated so a subsequent
210
+ * STOP/onTimeout can reference it. Runs on SDK 33 to avoid the API 34+ manifest-type check, which
211
+ * would require a test-specific {@code foregroundServiceType} declaration.
212
+ */
213
+ @Test
214
+ @Config(sdk = 33)
215
+ public void onStartCommand_startIntent_setsCurrentNotificationIdAndCallsStartForeground()
216
+ throws Exception {
217
+ createChannel();
218
+ ServiceController<ForegroundService> controller =
219
+ Robolectric.buildService(ForegroundService.class);
220
+ ForegroundService service = controller.create().get();
221
+
222
+ String id = "fgs-start-happy";
223
+ int result =
224
+ service.onStartCommand(
225
+ buildStartIntent(id, id.hashCode()), /* flags= */ 0, /* startId= */ 1);
226
+
227
+ assertEquals(Service.START_NOT_STICKY, result);
228
+ assertEquals(id, ForegroundService.mCurrentNotificationId);
229
+ // Robolectric's ShadowService records the most recent Notification passed to
230
+ // startForeground(); a non-null result proves the 3-arg startForeground() overload on the
231
+ // API 33 branch of onStartCommand was exercised and Android's contract was satisfied.
232
+ Notification posted = org.robolectric.Shadows.shadowOf(service).getLastForegroundNotification();
233
+ assertNotNull("startForeground() must have been called during the START path", posted);
234
+ // The private static mCurrentHashCode tracks the caller-supplied hash; verifying it via
235
+ // reflection proves the START branch fully ran (not just the early-return path).
236
+ Field hashField = ForegroundService.class.getDeclaredField("mCurrentHashCode");
237
+ hashField.setAccessible(true);
238
+ assertEquals(id.hashCode(), hashField.getInt(null));
239
+ }
240
+
241
+ /**
242
+ * Regression guard for the 9.1.13 {@code onTimeout(int)} fix (upstream invertase/notifee#703). On
243
+ * API 34, Android's single-argument {@code onTimeout} fires when a {@code shortService} FGS
244
+ * exceeds its 3-minute budget. The handler must:
245
+ *
246
+ * <ol>
247
+ * <li>emit a {@link NotificationEvent} with type {@link NotificationEvent#TYPE_FG_TIMEOUT},
248
+ * <li>carry the originating notification model so JS can correlate the event,
249
+ * <li>populate {@code startId} and {@code fgsType} extras (the latter is {@code -1} as a
250
+ * sentinel on the single-argument variant), and
251
+ * <li>reset the service's static tracking state.
252
+ * </ol>
253
+ */
254
+ @Test
255
+ @Config(sdk = 34)
256
+ public void onTimeout_api34_emitsFgTimeoutEventWithStartIdAndSentinelFgsType() throws Exception {
257
+ ServiceController<ForegroundService> controller =
258
+ Robolectric.buildService(ForegroundService.class);
259
+ ForegroundService service = controller.create().get();
260
+
261
+ String id = "fgs-timeout-api34";
262
+ seedActiveForegroundServiceState(id);
263
+
264
+ FgsEventCapture capture = new FgsEventCapture();
265
+ EventBus.register(capture);
266
+ try {
267
+ int startId = 42;
268
+ service.onTimeout(startId);
269
+
270
+ assertEquals(
271
+ "exactly one NotificationEvent should be emitted on timeout", 1, capture.events.size());
272
+ NotificationEvent event = capture.events.get(0);
273
+ assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
274
+ assertNotNull(
275
+ "timeout event must carry the originating notification", event.getNotification());
276
+ assertEquals(id, event.getNotification().getId());
277
+ assertNotNull("timeout event must carry startId/fgsType extras", event.getExtras());
278
+ assertEquals(startId, event.getExtras().getInt("startId"));
279
+ // handleTimeout is called with -1 as the fgsType sentinel from the single-argument overload.
280
+ assertEquals(-1, event.getExtras().getInt("fgsType"));
281
+ } finally {
282
+ EventBus.unregister(capture);
283
+ }
284
+
285
+ assertNull(
286
+ "mCurrentNotificationId must be cleared after onTimeout",
287
+ ForegroundService.mCurrentNotificationId);
288
+ assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
289
+ }
290
+
291
+ /**
292
+ * Regression guard for the API 35+ {@code onTimeout(int, int)} overload, which supersedes the
293
+ * single-argument variant and surfaces the type-specific timeout cause (e.g. {@code
294
+ * FOREGROUND_SERVICE_TYPE_DATA_SYNC}'s new Android 15 cumulative cap). The emitted event must
295
+ * carry the explicit {@code fgsType} value, not the sentinel.
296
+ */
297
+ @Test
298
+ @Config(sdk = 35)
299
+ public void onTimeout_api35_emitsFgTimeoutEventWithExplicitFgsType() throws Exception {
300
+ ServiceController<ForegroundService> controller =
301
+ Robolectric.buildService(ForegroundService.class);
302
+ ForegroundService service = controller.create().get();
303
+
304
+ String id = "fgs-timeout-api35";
305
+ seedActiveForegroundServiceState(id);
306
+
307
+ FgsEventCapture capture = new FgsEventCapture();
308
+ EventBus.register(capture);
309
+ try {
310
+ int startId = 7;
311
+ int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC;
312
+ service.onTimeout(startId, fgsType);
313
+
314
+ assertEquals(1, capture.events.size());
315
+ NotificationEvent event = capture.events.get(0);
316
+ assertEquals(NotificationEvent.TYPE_FG_TIMEOUT, event.getType());
317
+ assertEquals(id, event.getNotification().getId());
318
+ assertNotNull(event.getExtras());
319
+ assertEquals(startId, event.getExtras().getInt("startId"));
320
+ assertEquals(fgsType, event.getExtras().getInt("fgsType"));
321
+ } finally {
322
+ EventBus.unregister(capture);
323
+ }
324
+
325
+ assertNull(ForegroundService.mCurrentNotificationId);
326
+ assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
327
+ }
328
+
329
+ /**
330
+ * Defensive behaviour: if onTimeout fires on a service instance whose static state has already
331
+ * been cleared (a race between STOP and the Android system delivering a delayed timeout), the
332
+ * handler must not crash and must not post a stray event.
333
+ */
334
+ @Test
335
+ @Config(sdk = 34)
336
+ public void onTimeout_withNoActiveState_doesNotCrashOrEmitEvent() {
337
+ ServiceController<ForegroundService> controller =
338
+ Robolectric.buildService(ForegroundService.class);
339
+ ForegroundService service = controller.create().get();
340
+
341
+ FgsEventCapture capture = new FgsEventCapture();
342
+ EventBus.register(capture);
343
+ try {
344
+ service.onTimeout(/* startId= */ 99);
345
+ assertEquals(0, capture.events.size());
346
+ } finally {
347
+ EventBus.unregister(capture);
348
+ }
349
+ }
350
+
351
+ // ──────────────────────────────────────────────────────────────────────────
352
+ // Helpers
353
+ // ──────────────────────────────────────────────────────────────────────────
354
+
355
+ private static void createChannel() {
356
+ Context context = RuntimeEnvironment.getApplication();
357
+ NotificationManager nm =
358
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
359
+ if (nm != null && nm.getNotificationChannel(TEST_CHANNEL_ID) == null) {
360
+ NotificationChannel channel =
361
+ new NotificationChannel(
362
+ TEST_CHANNEL_ID, "FGS test channel", NotificationManager.IMPORTANCE_LOW);
363
+ nm.createNotificationChannel(channel);
364
+ }
365
+ }
366
+
367
+ private static Bundle buildNotificationBundle(String id) {
368
+ Bundle bundle = new Bundle();
369
+ bundle.putString("id", id);
370
+ bundle.putString("title", "FGS test " + id);
371
+ Bundle androidBundle = new Bundle();
372
+ androidBundle.putString("channelId", TEST_CHANNEL_ID);
373
+ bundle.putBundle("android", androidBundle);
374
+ return bundle;
375
+ }
376
+
377
+ private static Notification buildNotification() {
378
+ Context context = RuntimeEnvironment.getApplication();
379
+ return new NotificationCompat.Builder(context, TEST_CHANNEL_ID)
380
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
381
+ .setContentTitle("FGS test")
382
+ .build();
383
+ }
384
+
385
+ private static Intent buildStartIntent(String id, int hashCode) {
386
+ Intent intent = new Intent();
387
+ intent.setAction(ForegroundService.START_FOREGROUND_SERVICE_ACTION);
388
+ intent.putExtra("hashCode", hashCode);
389
+ intent.putExtra("notification", buildNotification());
390
+ intent.putExtra("notificationBundle", buildNotificationBundle(id));
391
+ return intent;
392
+ }
393
+
394
+ /**
395
+ * Populates the service's private static tracking fields directly, simulating the post-START
396
+ * state that onTimeout expects to observe. Using reflection here (rather than running a full
397
+ * START first) keeps the onTimeout tests SDK-independent — the START path would trip the API 34+
398
+ * manifest-type check, but onTimeout itself is sdk-agnostic because its body only uses pre-API-34
399
+ * primitives.
400
+ */
401
+ private static void seedActiveForegroundServiceState(String id) throws Exception {
402
+ ForegroundService.mCurrentNotificationId = id;
403
+ ForegroundService.mCurrentForegroundServiceType = 1;
404
+ setPrivateStatic("mCurrentNotificationBundle", buildNotificationBundle(id));
405
+ setPrivateStatic("mCurrentNotification", buildNotification());
406
+ setPrivateStatic("mCurrentHashCode", id.hashCode());
407
+ }
408
+
409
+ private static void setPrivateStatic(String name, Object value) throws Exception {
410
+ Field field = ForegroundService.class.getDeclaredField(name);
411
+ field.setAccessible(true);
412
+ field.set(null, value);
413
+ }
414
+
415
+ /**
416
+ * greenrobot EventBus subscriber that records every {@link NotificationEvent} posted during a
417
+ * test. {@link ThreadMode#POSTING} fires the subscriber synchronously on the caller thread, so
418
+ * the test can inspect {@code events} immediately after {@code post()} returns without any looper
419
+ * advancement.
420
+ */
421
+ public static class FgsEventCapture {
422
+ final List<NotificationEvent> events = new ArrayList<>();
423
+
424
+ @Subscribe(threadMode = ThreadMode.POSTING)
425
+ public void onNotificationEvent(NotificationEvent event) {
426
+ events.add(event);
427
+ }
428
+ }
182
429
  }
@@ -0,0 +1,81 @@
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.assertFalse;
20
+ import static org.junit.Assert.assertTrue;
21
+
22
+ import org.junit.Test;
23
+ import org.junit.runner.RunWith;
24
+ import org.robolectric.RobolectricTestRunner;
25
+
26
+ /**
27
+ * Unit tests for the BOOT_COUNT-based cold-start reschedule decision logic added for upstream
28
+ * invertase/notifee#734. Exhaustively covers the pure decision helper {@link
29
+ * InitProvider#shouldRescheduleAfterBoot}.
30
+ *
31
+ * <p>The integration path {@code runBootCheck → rescheduleNotifications} is intentionally not
32
+ * exercised here because {@code rescheduleNotifications} touches a real Room database via {@code
33
+ * WorkDataRepository.getInstance}, which is not set up in this unit-test environment. That path is
34
+ * covered by the instrumented {@code RebootRecoveryTest} and by the manual Step 4 smoke test.
35
+ */
36
+ @RunWith(RobolectricTestRunner.class)
37
+ public class InitProviderBootCheckTest {
38
+
39
+ @Test
40
+ public void shouldRescheduleAfterBoot_firstRun_withValidBootCount_returnsFalse() {
41
+ // lastKnown == -1 means "no baseline recorded yet". We record the baseline
42
+ // and skip reschedule to avoid firing stale triggers on fresh installs.
43
+ assertFalse(InitProvider.shouldRescheduleAfterBoot(5, -1));
44
+ }
45
+
46
+ @Test
47
+ public void shouldRescheduleAfterBoot_sameBoot_returnsFalse() {
48
+ // Same app process, same device boot → nothing to recover.
49
+ assertFalse(InitProvider.shouldRescheduleAfterBoot(5, 5));
50
+ }
51
+
52
+ @Test
53
+ public void shouldRescheduleAfterBoot_newBoot_returnsTrue() {
54
+ // Reboot detected since last run → recover scheduled alarms.
55
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(6, 5));
56
+ }
57
+
58
+ @Test
59
+ public void shouldRescheduleAfterBoot_bootCountDecreased_returnsTrue() {
60
+ // BOOT_COUNT decreasing is unexpected but possible (factory reset, counter
61
+ // rollover on exotic ROMs). Any change is treated as "unknown transition
62
+ // since last run" → conservative reschedule.
63
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(3, 5));
64
+ }
65
+
66
+ @Test
67
+ public void shouldRescheduleAfterBoot_bootCountUnavailable_returnsTrue() {
68
+ // BOOT_COUNT couldn't be read → we can't tell if a reboot happened, so we
69
+ // reschedule conservatively. This also covers emulators and rooted ROMs
70
+ // where the Settings.Global row is missing or throws.
71
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, 5));
72
+ }
73
+
74
+ @Test
75
+ public void shouldRescheduleAfterBoot_bootCountUnavailable_andFirstRun_returnsTrue() {
76
+ // Degenerate case: first run AND BOOT_COUNT unavailable. The "unavailable"
77
+ // branch wins over the "first run" branch because we genuinely don't know
78
+ // if the device has rebooted since install.
79
+ assertTrue(InitProvider.shouldRescheduleAfterBoot(-1, -1));
80
+ }
81
+ }
@@ -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
+ }