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.
@@ -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
+ }
@@ -2,16 +2,37 @@ package app.notifee.core.model;
2
2
 
3
3
  import static org.junit.Assert.assertEquals;
4
4
  import static org.junit.Assert.assertNull;
5
+ import static org.junit.Assert.assertTrue;
5
6
 
6
7
  import android.os.Bundle;
8
+ import java.util.Calendar;
9
+ import java.util.TimeZone;
10
+ import org.junit.After;
11
+ import org.junit.Assume;
7
12
  import org.junit.Before;
8
13
  import org.junit.Test;
14
+ import org.junit.runner.RunWith;
15
+ import org.robolectric.RobolectricTestRunner;
9
16
 
17
+ @RunWith(RobolectricTestRunner.class)
10
18
  public class TimestampTriggerModelTest {
19
+ private static final long ONE_MINUTE_MS = 60L * 1000L;
20
+ private static final long ONE_HOUR_MS = 60L * 60L * 1000L;
21
+ private static final long ONE_DAY_MS = 24L * ONE_HOUR_MS;
22
+
23
+ private static final int REPEAT_FREQUENCY_HOURLY = 0;
24
+ private static final int REPEAT_FREQUENCY_DAILY = 1;
25
+ private static final int REPEAT_FREQUENCY_WEEKLY = 2;
26
+
11
27
  private TimestampTriggerModel mTimestampTriggerModel = null;
28
+ private TimeZone mOriginalTimeZone;
29
+ private long mNow;
12
30
 
13
31
  @Before
14
32
  public void before() {
33
+ mOriginalTimeZone = TimeZone.getDefault();
34
+ mNow = System.currentTimeMillis();
35
+
15
36
  Bundle trigger = new Bundle();
16
37
  Bundle triggerComponents = new Bundle();
17
38
  triggerComponents.putInt("minute", 1);
@@ -20,16 +41,18 @@ public class TimestampTriggerModelTest {
20
41
  triggerComponents.putInt("month", 12);
21
42
  triggerComponents.putInt("weekday", 3);
22
43
  triggerComponents.putInt("weekdayOrdinal", 2);
23
-
24
- // The ISO 8601 week date of the year.
25
44
  triggerComponents.putInt("weekOfYear", 24);
26
- // The week number of the months.
27
45
  triggerComponents.putInt("weekOfMonth", 2);
28
46
 
29
47
  trigger.putBundle("components", triggerComponents);
30
48
  mTimestampTriggerModel = TimestampTriggerModel.fromBundle(trigger);
31
49
  }
32
50
 
51
+ @After
52
+ public void after() {
53
+ TimeZone.setDefault(mOriginalTimeZone);
54
+ }
55
+
33
56
  @Test
34
57
  public void testBundleValues() {
35
58
  assertEquals(
@@ -41,4 +64,177 @@ public class TimestampTriggerModelTest {
41
64
  assertEquals(
42
65
  "with no 'repeatFrequency', delay should be 0", 0, mTimestampTriggerModel.getDelay());
43
66
  }
67
+
68
+ // Regression tests for the DAILY/WEEKLY/HOURLY rescheduling cycle. Guard the fix for
69
+ // upstream invertase/notifee#839 (DAILY trigger fails to re-fire from day 2 onwards on
70
+ // Android) and #875 (DST-safe Calendar.add for repeat frequency) against future refactors
71
+ // of TimestampTriggerModel.setNextTimestamp().
72
+
73
+ private TimestampTriggerModel buildRepeatingTrigger(long timestamp, int repeatFrequency) {
74
+ Bundle trigger = new Bundle();
75
+ trigger.putLong("timestamp", timestamp);
76
+ trigger.putInt("repeatFrequency", repeatFrequency);
77
+ return TimestampTriggerModel.fromBundle(trigger);
78
+ }
79
+
80
+ @Test
81
+ public void setNextTimestamp_daily_advancesToTomorrowSameWallClock() {
82
+ long original = mNow - ONE_MINUTE_MS;
83
+ TimestampTriggerModel trigger = buildRepeatingTrigger(original, REPEAT_FREQUENCY_DAILY);
84
+
85
+ Calendar originalCal = Calendar.getInstance();
86
+ originalCal.setTimeInMillis(original);
87
+ int originalHour = originalCal.get(Calendar.HOUR_OF_DAY);
88
+ int originalMinute = originalCal.get(Calendar.MINUTE);
89
+ int originalSecond = originalCal.get(Calendar.SECOND);
90
+
91
+ trigger.setNextTimestamp();
92
+ long next = trigger.getTimestamp();
93
+
94
+ assertTrue("next timestamp must be in the future", next > mNow);
95
+ assertTrue("next timestamp must be within 25h", next < mNow + 25L * ONE_HOUR_MS);
96
+
97
+ Calendar nextCal = Calendar.getInstance();
98
+ nextCal.setTimeInMillis(next);
99
+ assertEquals("wall-clock hour preserved", originalHour, nextCal.get(Calendar.HOUR_OF_DAY));
100
+ assertEquals("wall-clock minute preserved", originalMinute, nextCal.get(Calendar.MINUTE));
101
+ assertEquals("wall-clock second preserved", originalSecond, nextCal.get(Calendar.SECOND));
102
+ }
103
+
104
+ @Test
105
+ public void setNextTimestamp_daily_multipleMissedFires_skipsToFuture() {
106
+ // Offset by 1 minute so the original does not land exactly on a multiple of 24h from mNow.
107
+ // Otherwise, with a deterministic clock (e.g. Robolectric), the while-loop's `<` comparison
108
+ // exits precisely at `cal == now` and next equals mNow, which is not strictly in the future.
109
+ long original = mNow - 3L * ONE_DAY_MS - ONE_MINUTE_MS;
110
+ TimestampTriggerModel trigger = buildRepeatingTrigger(original, REPEAT_FREQUENCY_DAILY);
111
+
112
+ trigger.setNextTimestamp();
113
+ long next = trigger.getTimestamp();
114
+
115
+ assertTrue("next timestamp must be in the future after 3 missed fires", next > mNow);
116
+ assertTrue("next timestamp must not be more than 25h ahead", next < mNow + 25L * ONE_HOUR_MS);
117
+ }
118
+
119
+ @Test
120
+ public void setNextTimestamp_weekly_advancesOneWeek() {
121
+ long original = mNow - ONE_MINUTE_MS;
122
+ TimestampTriggerModel trigger = buildRepeatingTrigger(original, REPEAT_FREQUENCY_WEEKLY);
123
+
124
+ trigger.setNextTimestamp();
125
+ long next = trigger.getTimestamp();
126
+
127
+ long lower = mNow + 6L * ONE_DAY_MS + 23L * ONE_HOUR_MS;
128
+ long upper = mNow + 7L * ONE_DAY_MS + ONE_HOUR_MS;
129
+ assertTrue("weekly next timestamp must be >= now + 6d23h", next >= lower);
130
+ assertTrue("weekly next timestamp must be <= now + 7d1h", next <= upper);
131
+ }
132
+
133
+ @Test
134
+ public void setNextTimestamp_hourly_advancesOneHour() {
135
+ long original = mNow - ONE_MINUTE_MS;
136
+ TimestampTriggerModel trigger = buildRepeatingTrigger(original, REPEAT_FREQUENCY_HOURLY);
137
+
138
+ trigger.setNextTimestamp();
139
+ long next = trigger.getTimestamp();
140
+
141
+ long lower = mNow + 59L * ONE_MINUTE_MS;
142
+ long upper = mNow + 61L * ONE_MINUTE_MS;
143
+ assertTrue("hourly next timestamp must be >= now + 59m", next >= lower);
144
+ assertTrue("hourly next timestamp must be <= now + 61m", next <= upper);
145
+ }
146
+
147
+ @Test
148
+ public void setNextTimestamp_dailyAcrossDstSpringForward() {
149
+ // Europe/Rome spring-forward 2026: 2026-03-29 02:00 local jumps to 03:00 local. The 29
150
+ // March 2026 calendar day is 23 hours long in Europe/Rome. Using 04:30 (NOT 01:30) as
151
+ // the wall-clock: 04:30 on 2026-03-28 is CET (UTC+1), while 04:30 on 2026-03-29 is CEST
152
+ // (UTC+2). Calendar.add(DAY_OF_MONTH, 1) preserves the local 04:30 wall-clock across the
153
+ // boundary; a hypothetical refactor to +86_400_000 ms fixed arithmetic would drift the
154
+ // wall-clock to 05:30 CEST on the day after the crossing. Using 01:30 would NOT
155
+ // discriminate because 01:30 sits in the same UTC offset on both sides of the transition.
156
+ TimeZone rome = TimeZone.getTimeZone("Europe/Rome");
157
+ TimeZone.setDefault(rome);
158
+
159
+ Calendar start = Calendar.getInstance(rome);
160
+ start.clear();
161
+ start.set(2026, Calendar.MARCH, 28, 4, 30, 0);
162
+ long originalTimestamp = start.getTimeInMillis();
163
+
164
+ // Assume the current wall-clock is in the post-spring-forward / pre-fall-back window for
165
+ // 2026. Outside this window the while-loop inside setNextTimestamp either does not run
166
+ // (future-dated start) or crosses an even number of DST boundaries whose effects cancel
167
+ // for fixed-ms arithmetic, making the assertion non-discriminative.
168
+ Calendar windowStart = Calendar.getInstance(rome);
169
+ windowStart.clear();
170
+ windowStart.set(2026, Calendar.MARCH, 30, 0, 0, 0);
171
+ Calendar windowEnd = Calendar.getInstance(rome);
172
+ windowEnd.clear();
173
+ windowEnd.set(2026, Calendar.OCTOBER, 24, 23, 59, 59);
174
+ long now = System.currentTimeMillis();
175
+ Assume.assumeTrue(
176
+ "spring-forward discrimination requires current time in [2026-03-30, 2026-10-24]"
177
+ + " Europe/Rome",
178
+ now >= windowStart.getTimeInMillis() && now <= windowEnd.getTimeInMillis());
179
+
180
+ TimestampTriggerModel trigger =
181
+ buildRepeatingTrigger(originalTimestamp, REPEAT_FREQUENCY_DAILY);
182
+ trigger.setNextTimestamp();
183
+ long next = trigger.getTimestamp();
184
+
185
+ Calendar nextCal = Calendar.getInstance(rome);
186
+ nextCal.setTimeInMillis(next);
187
+ assertTrue("next timestamp must be >= now", next >= now);
188
+ assertEquals(
189
+ "wall-clock hour must remain 4 after crossing spring-forward",
190
+ 4,
191
+ nextCal.get(Calendar.HOUR_OF_DAY));
192
+ assertEquals("wall-clock minute must remain 30", 30, nextCal.get(Calendar.MINUTE));
193
+ assertEquals("wall-clock second must remain 0", 0, nextCal.get(Calendar.SECOND));
194
+ }
195
+
196
+ @Test
197
+ public void setNextTimestamp_dailyAcrossDstFallBack() {
198
+ // Europe/Rome fall-back 2025: 2025-10-26 03:00 local reverts to 02:00 local. The 26
199
+ // October 2025 calendar day is 25 hours long. Starting at 2025-10-25 04:30 CEST (UTC+2)
200
+ // — the day before the fall-back — the local 04:30 wall-clock on 2025-10-26 is CET
201
+ // (UTC+1). Calendar.add preserves the 04:30 local wall-clock; +86_400_000 ms would drift
202
+ // it to 03:30 CET after the crossing.
203
+ TimeZone rome = TimeZone.getTimeZone("Europe/Rome");
204
+ TimeZone.setDefault(rome);
205
+
206
+ Calendar start = Calendar.getInstance(rome);
207
+ start.clear();
208
+ start.set(2025, Calendar.OCTOBER, 25, 4, 30, 0);
209
+ long originalTimestamp = start.getTimeInMillis();
210
+
211
+ // Assume the current wall-clock is in the post-fall-back-2025 / pre-spring-forward-2026
212
+ // window, so the setNextTimestamp loop crosses exactly one DST boundary (the fall-back)
213
+ // and the test remains discriminative against fixed-ms arithmetic.
214
+ Calendar windowStart = Calendar.getInstance(rome);
215
+ windowStart.clear();
216
+ windowStart.set(2025, Calendar.OCTOBER, 27, 0, 0, 0);
217
+ Calendar windowEnd = Calendar.getInstance(rome);
218
+ windowEnd.clear();
219
+ windowEnd.set(2026, Calendar.MARCH, 28, 23, 59, 59);
220
+ long now = System.currentTimeMillis();
221
+ Assume.assumeTrue(
222
+ "fall-back discrimination requires current time in [2025-10-27, 2026-03-28] Europe/Rome",
223
+ now >= windowStart.getTimeInMillis() && now <= windowEnd.getTimeInMillis());
224
+
225
+ TimestampTriggerModel trigger =
226
+ buildRepeatingTrigger(originalTimestamp, REPEAT_FREQUENCY_DAILY);
227
+ trigger.setNextTimestamp();
228
+ long next = trigger.getTimestamp();
229
+
230
+ Calendar nextCal = Calendar.getInstance(rome);
231
+ nextCal.setTimeInMillis(next);
232
+ assertTrue("next timestamp must be >= now", next >= now);
233
+ assertEquals(
234
+ "wall-clock hour must remain 4 after crossing fall-back",
235
+ 4,
236
+ nextCal.get(Calendar.HOUR_OF_DAY));
237
+ assertEquals("wall-clock minute must remain 30", 30, nextCal.get(Calendar.MINUTE));
238
+ assertEquals("wall-clock second must remain 0", 0, nextCal.get(Calendar.SECOND));
239
+ }
44
240
  }
@@ -0,0 +1,34 @@
1
+ package io.invertase.notifee
2
+
3
+ import com.facebook.react.bridge.WritableMap
4
+ import org.junit.Test
5
+ import org.mockito.ArgumentMatchers.anyInt
6
+ import org.mockito.ArgumentMatchers.anyString
7
+ import org.mockito.Mockito.mock
8
+ import org.mockito.Mockito.never
9
+ import org.mockito.Mockito.times
10
+ import org.mockito.Mockito.verify
11
+ import org.mockito.Mockito.`when`
12
+
13
+ class HeadlessTaskConfigTest {
14
+
15
+ // Regression guard for invertase/notifee#266: the init block must copy the caller's
16
+ // WritableMap before mutating it, otherwise a later .copy() on the already-consumed map crashes.
17
+ @Test
18
+ fun taskConfig_copiesParamsBeforeMutating_keepsCallerMapPristine() {
19
+ val original = mock(WritableMap::class.java)
20
+ val copy = mock(WritableMap::class.java)
21
+ `when`(original.copy()).thenReturn(copy)
22
+
23
+ HeadlessTask.TaskConfig(
24
+ "NotifeeHeadlessJS",
25
+ 60_000L,
26
+ original,
27
+ null,
28
+ )
29
+
30
+ verify(original, never()).putInt(anyString(), anyInt())
31
+ verify(original, times(1)).copy()
32
+ verify(copy, times(1)).putInt(anyString(), anyInt())
33
+ }
34
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "9.5.0";
1
+ export declare const version = "9.6.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by genversion.
2
- export const version = '9.5.0';
2
+ export const version = '9.6.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-notify-kit",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "author": "Marco Crupi",
5
5
  "description": "Maintained Notifee-compatible fork for React Native — advanced local notifications for Android & iOS.",
6
6
  "main": "dist/index.js",
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '9.5.0';
2
+ export const version = '9.6.0';