react-native-notify-kit 10.0.0 → 10.2.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 (44) hide show
  1. package/README.md +168 -100
  2. package/android/build.gradle +2 -2
  3. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +94 -2
  4. package/android/src/main/AndroidManifest.xml +8 -7
  5. package/android/src/main/java/app/notifee/core/ForegroundService.java +1 -0
  6. package/android/src/main/java/app/notifee/core/NotificationManager.java +8 -0
  7. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +16 -0
  8. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +8 -3
  9. package/android/src/main/java/app/notifee/core/model/TimestampTriggerModel.java +42 -27
  10. package/android/src/main/java/app/notifee/core/utility/ResourceUtils.java +30 -1
  11. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +6 -6
  12. package/android/src/test/java/app/notifee/core/model/NotificationAndroidModelSmallIconFallbackTest.java +74 -0
  13. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +154 -0
  14. package/android/src/test/java/app/notifee/core/utility/ResourceUtilsFallbackIconTest.java +116 -0
  15. package/dist/types/Module.d.ts +7 -7
  16. package/dist/types/Notification.d.ts +1 -1
  17. package/dist/types/NotificationIOS.d.ts +44 -21
  18. package/dist/types/NotificationIOS.js +15 -1
  19. package/dist/types/NotificationIOS.js.map +1 -1
  20. package/dist/types/Trigger.d.ts +45 -2
  21. package/dist/types/Trigger.js +22 -0
  22. package/dist/types/Trigger.js.map +1 -1
  23. package/dist/validators/validateIOSCategory.js +2 -2
  24. package/dist/validators/validateIOSCategory.js.map +1 -1
  25. package/dist/validators/validateTrigger.js +42 -1
  26. package/dist/validators/validateTrigger.js.map +1 -1
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +20 -10
  30. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.h +1 -2
  31. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +44 -35
  32. package/ios/NotifeeCore/NotifeeCore.m +1035 -99
  33. package/ios/NotifeeCore/NotifeeCoreUtil.h +36 -1
  34. package/ios/NotifeeCore/NotifeeCoreUtil.m +533 -9
  35. package/ios/RNNotifee/NotifeeApiModule.mm +2 -2
  36. package/package.json +1 -1
  37. package/server/README.md +4 -4
  38. package/src/types/Module.ts +7 -7
  39. package/src/types/Notification.ts +1 -1
  40. package/src/types/NotificationIOS.ts +43 -28
  41. package/src/types/Trigger.ts +45 -1
  42. package/src/validators/validateIOSCategory.ts +4 -2
  43. package/src/validators/validateTrigger.ts +62 -0
  44. package/src/version.ts +1 -1
@@ -53,7 +53,7 @@ android {
53
53
  defaultConfig {
54
54
  minSdk = 24
55
55
  targetSdk = 35
56
- multiDexEnabled true
56
+ multiDexEnabled = true
57
57
 
58
58
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
59
59
 
@@ -66,7 +66,7 @@ android {
66
66
 
67
67
  lint {
68
68
  disable 'GradleCompatible'
69
- abortOnError false
69
+ abortOnError = false
70
70
  }
71
71
 
72
72
  compileOptions {
@@ -14,8 +14,8 @@ package app.notifee.core;
14
14
  * NOT RUN IN CI.
15
15
  *
16
16
  * Instrumented regression suite for the reboot-recovery path in
17
- * NotifeeAlarmManager.rescheduleNotifications, covering three upstream
18
- * issues across five test cases:
17
+ * NotifeeAlarmManager.rescheduleNotifications, covering four upstream
18
+ * issues across six test cases:
19
19
  *
20
20
  * 1. rescheduleNotifications_advancesEveryAnchorBeforeCompleting
21
21
  * (upstream invertase/notifee#549 — reboot-recovery anchor persistence
@@ -54,6 +54,15 @@ package app.notifee.core;
54
54
  * handleStaleNonRepeatingTrigger routes the failure through the
55
55
  * resilient branch that still runs deleteById.
56
56
  *
57
+ * 6. rescheduleNotifications_nonRepeatingFuture_preservesAnchorAndRearmsAlarm
58
+ * (upstream invertase/notifee#991 — non-repeating TIMESTAMP triggers lost
59
+ * after device reboot on Android 14). Seeds a non-repeating row 5 minutes
60
+ * in the future and asserts the row survives reboot recovery with its
61
+ * anchor preserved AND a fresh AlarmManager PendingIntent registered.
62
+ * Complements the past-stale paths above by covering the happy-path
63
+ * branch where setNextTimestamp early-returns at TimestampTriggerModel:136
64
+ * and rescheduleNotification re-arms the original anchor.
65
+ *
57
66
  * Run manually via the smoke harness (recommended — wraps gradle + copies
58
67
  * reports + fails loud if no XML reports are found):
59
68
  *
@@ -133,6 +142,9 @@ public class RebootRecoveryTest {
133
142
  /** 48 hours before "now" — well beyond the 24h grace period. */
134
143
  private static final long STALE_BEYOND_GRACE_OFFSET_MS = 48 * HOUR_IN_MS;
135
144
 
145
+ /** 5 minutes after "now" — the future-non-repeating happy path for upstream #991. */
146
+ private static final long FUTURE_NON_REPEATING_OFFSET_MS = 5L * 60 * 1000;
147
+
136
148
  /**
137
149
  * Channel id used by the within-grace stale-trigger test fixture. Must match a real Android
138
150
  * notification channel created in {@link #setUp()} so {@code
@@ -181,6 +193,7 @@ public class RebootRecoveryTest {
181
193
  NotifeeAlarmManager.cancelNotification(staleWithinGraceTestId());
182
194
  NotifeeAlarmManager.cancelNotification(staleBeyondGraceTestId());
183
195
  NotifeeAlarmManager.cancelNotification(staleDisplayFailsTestId());
196
+ NotifeeAlarmManager.cancelNotification(nonRepeatingFutureTestId());
184
197
  // Dismiss any notifications the within-grace tests may have posted so the device
185
198
  // notification shade is left clean — the Room row is the test's contract, but the
186
199
  // visible notification is a side effect we should not leave behind.
@@ -387,6 +400,81 @@ public class RebootRecoveryTest {
387
400
  repo.getWorkDataById(id).get(5, TimeUnit.SECONDS) == null);
388
401
  }
389
402
 
403
+ /**
404
+ * Regression test for upstream invertase/notifee#991 — non-repeating TIMESTAMP triggers whose
405
+ * fire time is still in the future at boot must be re-armed with their original anchor preserved.
406
+ *
407
+ * <p>The existing #734 tests cover the past-stale branches of {@code
408
+ * handleStaleNonRepeatingTrigger}; this fills the happy-path gap. Seeds a non-repeating trigger 5
409
+ * minutes in the future, invokes reboot recovery, and asserts that:
410
+ *
411
+ * <ul>
412
+ * <li>the Room row survives (not stale → not deleted),
413
+ * <li>the timestamp is unchanged (non-repeating → {@code setNextTimestamp} early-returns at
414
+ * {@code TimestampTriggerModel.java:136-139}),
415
+ * <li>an {@code AlarmManager} {@code PendingIntent} has been re-registered for the row, proving
416
+ * {@code rescheduleNotification} → {@code scheduleTimestampTriggerNotification} reached the
417
+ * alarm-set call.
418
+ * </ul>
419
+ */
420
+ @Test
421
+ public void rescheduleNotifications_nonRepeatingFuture_preservesAnchorAndRearmsAlarm()
422
+ throws Exception {
423
+ long now = System.currentTimeMillis();
424
+ long futureAnchor = now + FUTURE_NON_REPEATING_OFFSET_MS;
425
+ String id = nonRepeatingFutureTestId();
426
+
427
+ repo.insert(buildNonRepeatingEntity(id, futureAnchor)).get(5, TimeUnit.SECONDS);
428
+ assertEquals(1, repo.getAll().get(5, TimeUnit.SECONDS).size());
429
+
430
+ new NotifeeAlarmManager().rescheduleNotifications(null);
431
+
432
+ PendingIntent pi = awaitPendingIntentRegistered(id);
433
+ assertNotNull(
434
+ "AlarmManager PendingIntent must be re-registered for the non-repeating future row " + id,
435
+ pi);
436
+
437
+ WorkDataEntity row = repo.getWorkDataById(id).get(5, TimeUnit.SECONDS);
438
+ assertNotNull("non-repeating future row must survive reboot recovery", row);
439
+ Bundle triggerBundle = ObjectUtils.bytesToBundle(row.getTrigger());
440
+ long preservedAnchor = ObjectUtils.getLong(triggerBundle.get("timestamp"));
441
+ assertEquals(
442
+ "non-repeating future trigger anchor must NOT be advanced — setNextTimestamp early-returns"
443
+ + " when repeatFrequency is -1 and timestamp is in the future",
444
+ futureAnchor,
445
+ preservedAnchor);
446
+ }
447
+
448
+ /**
449
+ * Polls until an {@code AlarmManager} {@code PendingIntent} keyed off {@code id.hashCode()} is
450
+ * registered (FLAG_NO_CREATE returns non-null). Times out via {@link #fail(String)} if the
451
+ * reschedule pass never reaches {@code alarmManager.set*}.
452
+ */
453
+ private PendingIntent awaitPendingIntentRegistered(String id) throws Exception {
454
+ Intent intent = new Intent(context, NotificationAlarmReceiver.class);
455
+ intent.putExtra("notificationId", id);
456
+ long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
457
+ while (System.currentTimeMillis() < deadline) {
458
+ PendingIntent pi =
459
+ PendingIntent.getBroadcast(
460
+ context,
461
+ id.hashCode(),
462
+ intent,
463
+ PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_MUTABLE);
464
+ if (pi != null) {
465
+ return pi;
466
+ }
467
+ Thread.sleep(POLL_INTERVAL_MS);
468
+ }
469
+ fail(
470
+ "AlarmManager PendingIntent for "
471
+ + id
472
+ + " was not registered within "
473
+ + POLL_DEADLINE_MS
474
+ + "ms — the #991 fix in rescheduleNotification may be broken");
475
+ return null; // unreachable
476
+ }
477
+
390
478
  /** Polls {@link WorkDataRepository#getWorkDataById(String)} until it returns {@code null}. */
391
479
  private void awaitRowDeleted(String id) throws Exception {
392
480
  long deadline = System.currentTimeMillis() + POLL_DEADLINE_MS;
@@ -566,4 +654,8 @@ public class RebootRecoveryTest {
566
654
  private static String staleDisplayFailsTestId() {
567
655
  return "reboot-recovery-stale-display-fails-test";
568
656
  }
657
+
658
+ private static String nonRepeatingFutureTestId() {
659
+ return "reboot-recovery-non-repeating-future-test";
660
+ }
569
661
  }
@@ -49,15 +49,16 @@
49
49
  </intent-filter>
50
50
  </receiver>
51
51
 
52
+ <!--
53
+ NotificationAlarmReceiver is invoked exclusively via PendingIntent broadcasts from
54
+ AlarmManager (NotifeeAlarmManager.scheduleTimestampTriggerNotification). It does not
55
+ handle any system-broadcast Intent action — onReceive dispatches intent.getExtras()
56
+ to NotifeeAlarmManager.displayScheduledNotification, which early-returns on null
57
+ extras. Reboot recovery is owned by RebootBroadcastReceiver above.
58
+ -->
52
59
  <receiver
53
60
  android:name="app.notifee.core.NotificationAlarmReceiver"
54
- android:exported="false">
55
- <intent-filter>
56
- <action android:name="android.intent.action.BOOT_COMPLETED" />
57
- <action android:name="android.intent.action.QUICKBOOT_POWERON" />
58
- <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
59
- </intent-filter>
60
- </receiver>
61
+ android:exported="false" />
61
62
 
62
63
  <!-- Broadcast Receiver -->
63
64
  <receiver
@@ -415,6 +415,7 @@ public class ForegroundService extends Service {
415
415
  * @return the raw {@code foregroundServiceType} bitmask from the manifest, or 0 if the service is
416
416
  * not declared or has no explicit type
417
417
  */
418
+ @RequiresApi(Build.VERSION_CODES.Q)
418
419
  private int getDeclaredForegroundServiceType() {
419
420
  try {
420
421
  ComponentName component = new ComponentName(this, ForegroundService.class);
@@ -312,6 +312,8 @@ class NotificationManager {
312
312
  } else {
313
313
  builder.setSmallIcon(smallIconId);
314
314
  }
315
+ } else {
316
+ builder.setSmallIcon(ResourceUtils.getFallbackSmallIconId(getApplicationContext()));
315
317
  }
316
318
 
317
319
  if (androidModel.getSortKey() != null) {
@@ -816,6 +818,12 @@ class NotificationManager {
816
818
  Boolean withAlarmManager = trigger.getWithAlarmManager();
817
819
  Context context = getApplicationContext();
818
820
 
821
+ if (!withAlarmManager && TimestampTriggerModel.MONTHLY.equals(trigger.getRepeatFrequency())) {
822
+ String message = "RepeatFrequency.MONTHLY is not supported without AlarmManager.";
823
+ Logger.e(TAG, message);
824
+ return Futures.immediateFailedFuture(new IllegalArgumentException(message));
825
+ }
826
+
819
827
  ListenableFuture<Void> insertFuture =
820
828
  WorkDataRepository.insertTriggerNotification(
821
829
  context, notificationModel, triggerBundle, withAlarmManager);
@@ -27,9 +27,25 @@ import android.content.Intent;
27
27
  */
28
28
  public class RebootBroadcastReceiver extends BroadcastReceiver {
29
29
  private static final String TAG = "RebootReceiver";
30
+ private static final String ACTION_QUICKBOOT_POWERON = "android.intent.action.QUICKBOOT_POWERON";
31
+ private static final String ACTION_HTC_QUICKBOOT_POWERON =
32
+ "com.htc.intent.action.QUICKBOOT_POWERON";
30
33
 
31
34
  @Override
32
35
  public void onReceive(Context context, Intent intent) {
36
+ if (intent == null) {
37
+ Logger.w(TAG, "Ignoring reboot event with null intent");
38
+ return;
39
+ }
40
+
41
+ String action = intent.getAction();
42
+ if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
43
+ && !ACTION_QUICKBOOT_POWERON.equals(action)
44
+ && !ACTION_HTC_QUICKBOOT_POWERON.equals(action)) {
45
+ Logger.w(TAG, "Ignoring unsupported reboot event action: " + action);
46
+ return;
47
+ }
48
+
33
49
  PendingResult pendingResult = goAsync();
34
50
  // Tracks whether the synchronous section successfully handed off to the
35
51
  // async reschedule path. If not, the finally block must call finish() to
@@ -533,9 +533,14 @@ public class NotificationAndroidModel {
533
533
  int smallIconId = ResourceUtils.getImageResourceId(rawIcon);
534
534
 
535
535
  if (smallIconId == 0) {
536
- Logger.d(
537
- "NotificationAndroidModel",
538
- String.format("Notification small icon '%s' could not be found", rawIcon));
536
+ Logger.w(
537
+ TAG,
538
+ String.format(
539
+ "Notification small icon '%s' could not be resolved; falling back to the app"
540
+ + " launcher icon. Common causes: asset only in src/debug/res, R8 resource"
541
+ + " shrinking, or name mismatch (names must be lowercase with underscores)."
542
+ + " See README Troubleshooting.",
543
+ rawIcon));
539
544
  return null;
540
545
  }
541
546
 
@@ -30,13 +30,13 @@ public class TimestampTriggerModel {
30
30
  private Boolean mWithAlarmManager = false;
31
31
  private AlarmType mAlarmType = AlarmType.SET_EXACT;
32
32
  private String mRepeatFrequency = null;
33
+ private int mRepeatInterval = 1;
33
34
  private Long mTimestamp = null;
34
35
 
35
36
  public static final String HOURLY = "HOURLY";
36
37
  public static final String DAILY = "DAILY";
37
38
  public static final String WEEKLY = "WEEKLY";
38
-
39
- private static final long HOUR_IN_MS = 60L * 60 * 1000;
39
+ public static final String MONTHLY = "MONTHLY";
40
40
 
41
41
  private static final String TAG = "TimeTriggerModel";
42
42
 
@@ -47,6 +47,7 @@ public class TimestampTriggerModel {
47
47
  TimeUnit timeUnit = null;
48
48
  if (mTimeTriggerBundle.containsKey("repeatFrequency")) {
49
49
  int repeatFrequency = ObjectUtils.getInt(mTimeTriggerBundle.get("repeatFrequency"));
50
+ mRepeatInterval = getRepeatInterval(mTimeTriggerBundle.get("repeatInterval"));
50
51
  mTimestamp = ObjectUtils.getLong(mTimeTriggerBundle.get("timestamp"));
51
52
 
52
53
  switch (repeatFrequency) {
@@ -54,21 +55,24 @@ public class TimestampTriggerModel {
54
55
  // default value for one-time trigger
55
56
  break;
56
57
  case 0:
57
- mInterval = 1;
58
+ mInterval = mRepeatInterval;
58
59
  mTimeUnit = TimeUnit.HOURS;
59
60
  mRepeatFrequency = HOURLY;
60
61
  break;
61
62
  case 1:
62
- mInterval = 1;
63
+ mInterval = mRepeatInterval;
63
64
  mTimeUnit = TimeUnit.DAYS;
64
65
  mRepeatFrequency = DAILY;
65
66
  break;
66
67
  case 2:
67
68
  // weekly, 7 days
68
- mInterval = 7;
69
+ mInterval = 7 * mRepeatInterval;
69
70
  mTimeUnit = TimeUnit.DAYS;
70
71
  mRepeatFrequency = WEEKLY;
71
72
  break;
73
+ case 3:
74
+ mRepeatFrequency = MONTHLY;
75
+ break;
72
76
  }
73
77
  }
74
78
 
@@ -139,33 +143,27 @@ public class TimestampTriggerModel {
139
143
  return;
140
144
  }
141
145
 
142
- long timestamp = getTimestamp();
146
+ Calendar cal = Calendar.getInstance();
147
+ cal.setTimeInMillis(getTimestamp());
143
148
 
149
+ int field;
144
150
  if (HOURLY.equals(mRepeatFrequency)) {
145
- // Hourly: fixed ms is correct — no wall-clock preservation needed
146
- while (timestamp < System.currentTimeMillis()) {
147
- timestamp += HOUR_IN_MS;
148
- }
149
- this.mTimestamp = timestamp;
151
+ field = Calendar.HOUR_OF_DAY;
152
+ } else if (DAILY.equals(mRepeatFrequency)) {
153
+ field = Calendar.DAY_OF_MONTH;
154
+ } else if (WEEKLY.equals(mRepeatFrequency)) {
155
+ field = Calendar.WEEK_OF_YEAR;
156
+ } else if (MONTHLY.equals(mRepeatFrequency)) {
157
+ field = Calendar.MONTH;
150
158
  } else {
151
- // Daily/Weekly: use Calendar.add() to preserve local wall-clock time across DST boundaries.
152
- // Adding fixed ms (86,400,000) breaks when a calendar day is 23 or 25 hours during DST.
153
- Calendar cal = Calendar.getInstance();
154
- cal.setTimeInMillis(timestamp);
155
-
156
- int field;
157
- if (WEEKLY.equals(mRepeatFrequency)) {
158
- field = Calendar.WEEK_OF_YEAR;
159
- } else {
160
- field = Calendar.DAY_OF_MONTH;
161
- }
162
-
163
- while (cal.getTimeInMillis() < System.currentTimeMillis()) {
164
- cal.add(field, 1);
165
- }
159
+ return;
160
+ }
166
161
 
167
- this.mTimestamp = cal.getTimeInMillis();
162
+ while (cal.getTimeInMillis() < System.currentTimeMillis()) {
163
+ cal.add(field, mRepeatInterval);
168
164
  }
165
+
166
+ this.mTimestamp = cal.getTimeInMillis();
169
167
  }
170
168
 
171
169
  public enum AlarmType {
@@ -196,6 +194,23 @@ public class TimestampTriggerModel {
196
194
  return mRepeatFrequency;
197
195
  }
198
196
 
197
+ private int getRepeatInterval(Object repeatInterval) {
198
+ if (!(repeatInterval instanceof Number)) {
199
+ return 1;
200
+ }
201
+
202
+ double interval = ((Number) repeatInterval).doubleValue();
203
+ if (Double.isNaN(interval)
204
+ || Double.isInfinite(interval)
205
+ || interval <= 0
206
+ || interval % 1 != 0
207
+ || interval > Integer.MAX_VALUE) {
208
+ return 1;
209
+ }
210
+
211
+ return (int) interval;
212
+ }
213
+
199
214
  public Bundle toBundle() {
200
215
  Bundle bundle = (Bundle) mTimeTriggerBundle.clone();
201
216
  if (mTimestamp != null) {
@@ -18,6 +18,7 @@ package app.notifee.core.utility;
18
18
  */
19
19
 
20
20
  import android.content.Context;
21
+ import android.content.pm.ApplicationInfo;
21
22
  import android.graphics.Bitmap;
22
23
  import android.graphics.Canvas;
23
24
  import android.graphics.Color;
@@ -43,6 +44,7 @@ import com.facebook.imagepipeline.request.ImageRequestBuilder;
43
44
  import com.google.common.util.concurrent.ListenableFuture;
44
45
  import com.google.common.util.concurrent.SettableFuture;
45
46
  import java.util.HashMap;
47
+ import java.util.Locale;
46
48
  import java.util.Map;
47
49
 
48
50
  public class ResourceUtils {
@@ -223,13 +225,40 @@ public class ResourceUtils {
223
225
  return resourceId;
224
226
  }
225
227
 
228
+ /**
229
+ * Returns a resource id guaranteed to be usable as a smallIcon. Three-layer fallback: (1) {@code
230
+ * applicationInfo.icon}, (2) {@code applicationInfo.logo}, (3) the system {@code
231
+ * android.R.drawable.ic_dialog_info} drawable. Never returns 0, never throws.
232
+ *
233
+ * <p>Used by {@link app.notifee.core.NotificationManager} to keep a notification valid when the
234
+ * user-supplied {@code smallIcon} string cannot be resolved to a drawable at runtime.
235
+ */
236
+ public static int getFallbackSmallIconId(Context context) {
237
+ try {
238
+ if (context != null) {
239
+ ApplicationInfo info = context.getApplicationInfo();
240
+ if (info != null) {
241
+ if (info.icon != 0) {
242
+ return info.icon;
243
+ }
244
+ if (info.logo != 0) {
245
+ return info.logo;
246
+ }
247
+ }
248
+ }
249
+ } catch (Exception e) {
250
+ Logger.w(TAG, "getFallbackSmallIconId -> falling back to system default icon", e);
251
+ }
252
+ return android.R.drawable.ic_dialog_info;
253
+ }
254
+
226
255
  /** Attempts to find a resource id by name and type */
227
256
  private static int getResourceIdByName(String name, String type) {
228
257
  if (name == null || name.isEmpty()) {
229
258
  return 0;
230
259
  }
231
260
 
232
- name = name.toLowerCase().replace("-", "_");
261
+ name = name.toLowerCase(Locale.ROOT).replace("-", "_");
233
262
 
234
263
  String key = name + "_" + type;
235
264
 
@@ -157,7 +157,7 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
157
157
 
158
158
  override fun openAlarmPermissionSettings(promise: Promise) {
159
159
  Notifee.getInstance()
160
- .openAlarmPermissionSettings(getCurrentActivity()) { e, _ ->
160
+ .openAlarmPermissionSettings(getReactApplicationContext().getCurrentActivity()) { e, _ ->
161
161
  NotifeeReactUtils.promiseResolver(promise, e)
162
162
  }
163
163
  }
@@ -220,7 +220,7 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
220
220
 
221
221
  override fun getInitialNotification(promise: Promise) {
222
222
  Notifee.getInstance()
223
- .getInitialNotification(getCurrentActivity()) { e, bundle ->
223
+ .getInitialNotification(getReactApplicationContext().getCurrentActivity()) { e, bundle ->
224
224
  NotifeeReactUtils.promiseResolver(promise, e, bundle)
225
225
  }
226
226
  }
@@ -242,7 +242,7 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
242
242
  return
243
243
  }
244
244
 
245
- val activity = getCurrentActivity() as? PermissionAwareActivity
245
+ val activity = getReactApplicationContext().getCurrentActivity() as? PermissionAwareActivity
246
246
  if (activity == null) {
247
247
  Logger.d(
248
248
  "requestPermission",
@@ -277,14 +277,14 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
277
277
 
278
278
  override fun openNotificationSettings(channelId: String?, promise: Promise) {
279
279
  Notifee.getInstance()
280
- .openNotificationSettings(channelId, getCurrentActivity()) { e, _ ->
280
+ .openNotificationSettings(channelId, getReactApplicationContext().getCurrentActivity()) { e, _ ->
281
281
  NotifeeReactUtils.promiseResolver(promise, e)
282
282
  }
283
283
  }
284
284
 
285
285
  override fun openBatteryOptimizationSettings(promise: Promise) {
286
286
  Notifee.getInstance()
287
- .openBatteryOptimizationSettings(getCurrentActivity()) { e, _ ->
287
+ .openBatteryOptimizationSettings(getReactApplicationContext().getCurrentActivity()) { e, _ ->
288
288
  NotifeeReactUtils.promiseResolver(promise, e)
289
289
  }
290
290
  }
@@ -305,7 +305,7 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
305
305
 
306
306
  override fun openPowerManagerSettings(promise: Promise) {
307
307
  Notifee.getInstance()
308
- .openPowerManagerSettings(getCurrentActivity()) { e, _ ->
308
+ .openPowerManagerSettings(getReactApplicationContext().getCurrentActivity()) { e, _ ->
309
309
  NotifeeReactUtils.promiseResolver(promise, e)
310
310
  }
311
311
  }
@@ -0,0 +1,74 @@
1
+ package app.notifee.core.model;
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.assertNull;
20
+
21
+ import android.os.Bundle;
22
+ import app.notifee.core.ContextHolder;
23
+ import org.junit.Before;
24
+ import org.junit.Test;
25
+ import org.junit.runner.RunWith;
26
+ import org.robolectric.RobolectricTestRunner;
27
+ import org.robolectric.RuntimeEnvironment;
28
+
29
+ /**
30
+ * Unit coverage for {@link NotificationAndroidModel#getSmallIcon()}.
31
+ *
32
+ * <p>The method returns {@code null} when the smallIcon string cannot be resolved to a valid
33
+ * resource id. This is the trigger condition that {@link app.notifee.core.NotificationManager}
34
+ * translates into the launcher-icon fallback (upstream invertase/notifee#733).
35
+ *
36
+ * <p>The happy path (valid resource id returned) is covered end-to-end by smoke tests on real
37
+ * devices — unit-testing it here would require a real drawable registered against the test
38
+ * application package, which Robolectric does not stage by default.
39
+ */
40
+ @RunWith(RobolectricTestRunner.class)
41
+ public class NotificationAndroidModelSmallIconFallbackTest {
42
+
43
+ @Before
44
+ public void setUp() {
45
+ // getSmallIcon() -> ResourceUtils.getImageResourceId() -> getResourceIdByName() reads
46
+ // ContextHolder.getApplicationContext() to call context.getResources().getIdentifier(...).
47
+ // Robolectric does not auto-populate ContextHolder, so we wire it up here. Same pattern
48
+ // used by ForegroundServiceTest and NotifeeAlarmManagerHandleStaleTest.
49
+ ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
50
+ }
51
+
52
+ @Test
53
+ public void returnsNull_whenSmallIconKeyMissing() {
54
+ Bundle bundle = new Bundle();
55
+ NotificationAndroidModel model = NotificationAndroidModel.fromBundle(bundle);
56
+ assertNull(model.getSmallIcon());
57
+ }
58
+
59
+ @Test
60
+ public void returnsNull_whenSmallIconCannotBeResolved() {
61
+ Bundle bundle = new Bundle();
62
+ bundle.putString("smallIcon", "nonexistent_icon_name_for_test");
63
+ NotificationAndroidModel model = NotificationAndroidModel.fromBundle(bundle);
64
+ assertNull(model.getSmallIcon());
65
+ }
66
+
67
+ @Test
68
+ public void returnsNull_whenSmallIconEmptyString() {
69
+ Bundle bundle = new Bundle();
70
+ bundle.putString("smallIcon", "");
71
+ NotificationAndroidModel model = NotificationAndroidModel.fromBundle(bundle);
72
+ assertNull(model.getSmallIcon());
73
+ }
74
+ }