react-native-notify-kit 10.9.1 → 10.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,10 @@ This fork fills the gap: it preserves all of Notifee's advanced features, migrat
55
55
 
56
56
  The native core (NotifeeCore) is compiled from source as part of the bridge module (since 9.2.0) and the public API is **100% compatible** with the original `@notifee/react-native` — migration is a safe, drop-in replacement.
57
57
 
58
+ **10.10.1 iOS NSE and Android permission fixes — [#95](https://github.com/marcocrupi/react-native-notify-kit/issues/95), [#92](https://github.com/marcocrupi/react-native-notify-kit/issues/92):** Expo and bare CLI NSE generators now set `$NotifeeExtension = true` before autolinking so only `RNNotifeeCore` is compiled, incremental `expo prebuild` keeps React Native's `RCTNewArchEnabled` entry in the NSE `Info.plist`, iOS delegate forwarding contains duplicate-peer and synchronous delegate cycles, and Android `requestPermission()` starts on the main thread.
59
+
60
+ **10.10.0 Android permissions:** new `shouldShowRequestPermissionRationale()` query for Android's current recommendation to show explanatory UI before requesting notification permission. The result does not determine whether another permission dialog can appear.
61
+
58
62
  **10.9.1 Android fixes — [#88](https://github.com/marcocrupi/react-native-notify-kit/issues/88):** `getInitialNotification()` no longer replays consumed identifiable interactions; new interactions, including taps reusing a notification ID, remain available. Supported recovery and native full-screen capture use the [documented one-shot and latest-wins semantics and limits](https://docs.page/marcocrupi/react-native-notify-kit/react-native/events#android-consumption-and-recovery). Related upstream [notifee#880](https://github.com/invertase/notifee/issues/880) is not claimed fully resolved by this qualification.
59
63
 
60
64
  ## Installation
@@ -423,6 +427,11 @@ cd ios && pod install
423
427
 
424
428
  The CLI scaffolds a Swift NSE target (default name: `NotifyKitNSE`), patches your Podfile, and wires `.pbxproj`. Open your `.xcworkspace` in Xcode, verify the NSE target's signing, and build. Expo users should use the config plugin instead of running `init-nse` against generated native folders. See [docs/fcm-mode.mdx#ios-nse-setup](docs/fcm-mode.mdx#ios-nse-setup) for the full CLI reference (target name, bundle suffix, `--dry-run`, `--force`).
425
429
 
430
+
431
+ Both generators set `$NotifeeExtension = true` globally before autolinking so the host uses `RNNotifeeCore` rather than compiling a second core. Existing literal `true` is preserved; conflicting or conditional flag settings stop generation before the Podfile is written. Ruby with its standard `Ripper` parser (part of the CocoaPods toolchain) is needed to validate an existing flag configuration.
432
+
433
+ For an existing bare NSE, review your Podfile and add an unconditional `$NotifeeExtension = true` after its Ruby header comments and before requires/autolinking, then run `pod install`. Keep the existing target and custom service files. The CLI still rejects an existing target without `--force`; `--force` overwrites generated files and is not needed for this Podfile repair. `$NotifeeCoreFromSources` remains a separate development configuration. Native cycle containment prevents recursive forwarding; it does not make duplicate-core builds supported or recover a delegate already lost by another SDK.
434
+
426
435
  ### Manual setup
427
436
 
428
437
  For bare React Native projects with non-standard iOS paths or heavily-customized Xcode configurations where automation cannot patch the project cleanly:
@@ -532,7 +541,7 @@ This fork is a complete migration to React Native's **New Architecture**:
532
541
  - **Single Android module** — the original Notifee shipped a pre-compiled AAR bundled inside the npm tarball under a frozen Maven coordinate; this fork compiles the core from source as part of the React Native bridge module on every consumer build. Eliminates the `FAIL_ON_PROJECT_REPOS` issue on RN 0.74+ and the Gradle cache staleness bug that could serve outdated bytecode after `yarn upgrade`.
533
542
  - **Expo CNG development builds** — iOS FCM Mode NSE automation and Android foreground service manifest configuration are available through the config plugin. Android Expo FCM smoke validation uses RNFirebase data-only messages plus `notifee.handleFcmMessage`.
534
543
  - **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
535
- - **35 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
544
+ - **36 upstream bugs fixed** — see [Bugs Fixed from Upstream Notifee](#bugs-fixed-from-upstream-notifee) below
536
545
  - **Reliable trigger notifications** — AlarmManager is the default backend instead of WorkManager, with automatic fallback when exact alarm permission is not granted
537
546
  - **Custom repeat intervals for timestamp triggers** — `TimestampTrigger.repeatInterval` supports calendar-based recurrences such as every 2 days, every 2 weeks, or every 3 months from a selected start timestamp. On iOS, repeating timestamp triggers now use a bounded rolling schedule of one-shot local notifications instead of native repeating calendar triggers; this enables custom repeat intervals and start-date-respecting recurrence, but apps that relied on native iOS repeating triggers being scheduled indefinitely should review the iOS notes in the [Triggers guide](docs/react-native/triggers.mdx#custom-repeat-intervals).
538
547
  - **New API: `setNotificationConfig()`** — opt-out flag to prevent Notifee from intercepting iOS remote notification handlers (see [New APIs](#new-apis) below)
@@ -582,6 +591,7 @@ This fork fixes the following bugs that were never resolved in the original Noti
582
591
  | `ObjectAlreadyConsumedException` in headless task when the same `WritableMap` is reused or the `taskConfig` accessor is read twice — `TaskConfig.init` mutated the caller's map instead of copying it first. Latent in most apps but observed in production by upstream users with high-frequency headless events | Android | [#266](https://github.com/invertase/notifee/issues/266) | 9.6.0 |
583
592
  | `getDisplayedNotifications()` returned no `data` field on Android, breaking iOS/Android API symmetry where iOS exposes custom keys via `parseDataFromUserInfo:` (see platform limitation note below — the fix is API parity for app-posted notifications, not a workaround for FCM background auto-display) | Android | [#393](https://github.com/invertase/notifee/issues/393) | 9.7.0 |
584
593
  | Small icon resolution failure in release builds causes `IllegalArgumentException` at `NotificationCompat.Builder.build()` — library now falls back to the app launcher icon and logs a warning instead of failing the notification display | Android | [#733](https://github.com/invertase/notifee/issues/733) | 10.1.0 |
594
+ | Android 13+ `requestPermission()` can fail with `NullPointerException` in `View.dispatchCancelPendingInputEvents()` — `Activity.requestPermissions()` was started on the native modules thread instead of the UI thread | Android | [#1064](https://github.com/invertase/notifee/issues/1064) | 10.10.1 |
585
595
 
586
596
  > **Important note on `getDisplayedNotifications()` and FCM custom data on Android.**
587
597
  >
@@ -923,6 +933,20 @@ if (settings.android.fullScreenIntent === AndroidNotificationSetting.DISABLED) {
923
933
  notification will be presented full-screen. See [Android Behaviour: Full-screen](https://docs.page/marcocrupi/react-native-notify-kit/react-native/android/behaviour#full-screen)
924
934
  for the full requirements and presentation behavior.
925
935
 
936
+ ### `shouldShowRequestPermissionRationale` (Android)
937
+
938
+ `await notifee.shouldShowRequestPermissionRationale()` queries Android's current recommendation
939
+ for explanatory UI before requesting `POST_NOTIFICATIONS`. It does not request permission or
940
+ change request history. `false` does not prove permanent denial or whether another dialog can
941
+ appear, even when `getNotificationSettings()` reports `DENIED`.
942
+
943
+ On Android 13+ it returns the current Activity's platform result; without an Activity it rejects
944
+ with `ERR_NO_ACTIVITY`. Other native failures reject. Below API 33 and on other platforms it
945
+ resolves `false` (not applicable). Query again after returning from Settings. Target SDK,
946
+ permission declarations and system policy affect Android's behavior. See
947
+ [Android Permissions: Explaining a permission request](https://docs.page/marcocrupi/react-native-notify-kit/react-native/android/permissions#explaining-a-permission-request)
948
+ for a consumer example and platform limitations.
949
+
926
950
  ### `setNotificationConfig` (iOS)
927
951
 
928
952
  Controls whether Notifee intercepts remote (push) notification tap events on iOS.
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.Promise
15
15
  import com.facebook.react.bridge.ReactApplicationContext
16
16
  import com.facebook.react.bridge.ReadableArray
17
17
  import com.facebook.react.bridge.ReadableMap
18
+ import com.facebook.react.bridge.UiThreadUtil
18
19
  import com.facebook.react.modules.core.PermissionAwareActivity
19
20
  import com.facebook.react.modules.core.PermissionListener
20
21
 
@@ -233,6 +234,29 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
233
234
  }
234
235
  }
235
236
 
237
+ override fun shouldShowRequestPermissionRationale(promise: Promise) {
238
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
239
+ promise.resolve(false)
240
+ return
241
+ }
242
+
243
+ try {
244
+ val activity = getReactApplicationContext().getCurrentActivity()
245
+ if (activity == null) {
246
+ promise.reject(
247
+ "ERR_NO_ACTIVITY",
248
+ "A current Activity is required to query notification permission rationale.",
249
+ )
250
+ return
251
+ }
252
+ promise.resolve(
253
+ activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS),
254
+ )
255
+ } catch (e: Exception) {
256
+ promise.reject(e)
257
+ }
258
+ }
259
+
236
260
  override fun requestPermission(permissions: ReadableMap, promise: Promise) {
237
261
  // permissions parameter is ignored on Android — only used by iOS
238
262
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@@ -261,18 +285,22 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
261
285
  NotifeeReactUtils.promiseResolver(promise, e, bundle)
262
286
  }
263
287
 
264
- try {
265
- activity.requestPermissions(
266
- arrayOf(Manifest.permission.POST_NOTIFICATIONS),
267
- Notifee.REQUEST_CODE_NOTIFICATION_PERMISSION,
268
- this,
269
- )
270
- } catch (e: Exception) {
271
- Logger.d(
272
- "requestPermission",
273
- "Failed to request POST_NOTIFICATIONS permission: ${e.message}",
274
- )
275
- NotifeeReactUtils.promiseResolver(promise, e)
288
+ // starting the permission activity walks the view tree, so it must not run on the
289
+ // native modules thread while the UI thread changes it
290
+ UiThreadUtil.runOnUiThread {
291
+ try {
292
+ activity.requestPermissions(
293
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
294
+ Notifee.REQUEST_CODE_NOTIFICATION_PERMISSION,
295
+ this,
296
+ )
297
+ } catch (e: Exception) {
298
+ Logger.d(
299
+ "requestPermission",
300
+ "Failed to request POST_NOTIFICATIONS permission: ${e.message}",
301
+ )
302
+ NotifeeReactUtils.promiseResolver(promise, e)
303
+ }
276
304
  }
277
305
  }
278
306
 
@@ -0,0 +1,144 @@
1
+ package io.invertase.notifee;
2
+
3
+ import static org.mockito.Mockito.mock;
4
+ import static org.mockito.Mockito.mockStatic;
5
+ import static org.mockito.Mockito.times;
6
+ import static org.mockito.Mockito.verify;
7
+ import static org.mockito.Mockito.verifyNoInteractions;
8
+ import static org.mockito.Mockito.verifyNoMoreInteractions;
9
+ import static org.mockito.Mockito.when;
10
+
11
+ import android.Manifest;
12
+ import android.app.Activity;
13
+ import android.os.Build;
14
+ import com.facebook.react.bridge.Promise;
15
+ import com.facebook.react.bridge.ReactApplicationContext;
16
+ import org.junit.Before;
17
+ import org.junit.Test;
18
+ import org.junit.runner.RunWith;
19
+ import org.robolectric.RobolectricTestRunner;
20
+ import org.robolectric.annotation.Config;
21
+
22
+ /** Bridge behavior only: mocked platform recommendations are not Android dialog evidence. */
23
+ @RunWith(RobolectricTestRunner.class)
24
+ @Config(
25
+ manifest = Config.NONE,
26
+ sdk = {24, 32, 33, 36})
27
+ public class PermissionRationaleTest {
28
+ private ReactApplicationContext context;
29
+ private Activity activity;
30
+ private NotifeeApiModule module;
31
+ private Promise promise;
32
+
33
+ @Before
34
+ public void setUp() {
35
+ context = mock(ReactApplicationContext.class);
36
+ activity = mock(Activity.class);
37
+ when(context.getCurrentActivity()).thenReturn(activity);
38
+ module = new NotifeeApiModule(context);
39
+ promise = mock(Promise.class);
40
+ }
41
+
42
+ private void query(Promise target) throws Exception {
43
+ module.shouldShowRequestPermissionRationale(target);
44
+ }
45
+
46
+ @Test
47
+ public void returnsCurrentPlatformRecommendationWithoutOtherActivityCalls() throws Exception {
48
+ when(activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS))
49
+ .thenReturn(true, false, true);
50
+ for (boolean platformValue : new boolean[] {true, false, true}) {
51
+ Promise result = mock(Promise.class);
52
+ query(result);
53
+ verify(result).resolve(Build.VERSION.SDK_INT < 33 ? false : platformValue);
54
+ verifyNoMoreInteractions(result);
55
+ }
56
+ if (Build.VERSION.SDK_INT >= 33) {
57
+ verify(activity, times(3))
58
+ .shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS);
59
+ }
60
+ verifyNoMoreInteractions(activity);
61
+ }
62
+
63
+ @Test
64
+ public void missingActivityIsUnavailableOn33AndNotApplicableBefore33() throws Exception {
65
+ when(context.getCurrentActivity()).thenReturn(null);
66
+ query(promise);
67
+ if (Build.VERSION.SDK_INT >= 33) {
68
+ verify(promise)
69
+ .reject(
70
+ "ERR_NO_ACTIVITY",
71
+ "A current Activity is required to query notification permission rationale.");
72
+ } else {
73
+ verify(promise).resolve(false);
74
+ }
75
+ verifyNoMoreInteractions(promise);
76
+ verifyNoInteractions(activity);
77
+ }
78
+
79
+ @Test
80
+ public void nativeFailureRejectsRatherThanInventingANegativeRecommendation() throws Exception {
81
+ RuntimeException failure = new SecurityException("platform query failed");
82
+ when(activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS))
83
+ .thenThrow(failure);
84
+ query(promise);
85
+ if (Build.VERSION.SDK_INT >= 33) {
86
+ verify(promise).reject(failure);
87
+ } else {
88
+ verify(promise).resolve(false);
89
+ }
90
+ verifyNoMoreInteractions(promise);
91
+ }
92
+
93
+ @Test
94
+ public void activityLookupFailureRejectsAndALaterQueryCanRecover() throws Exception {
95
+ SecurityException failure = new SecurityException("Activity lookup failed");
96
+ when(context.getCurrentActivity()).thenThrow(failure).thenReturn(activity);
97
+ query(promise);
98
+ if (Build.VERSION.SDK_INT >= 33) {
99
+ verify(promise).reject(failure);
100
+ } else {
101
+ verify(promise).resolve(false);
102
+ }
103
+ Promise recovered = mock(Promise.class);
104
+ query(recovered);
105
+ verify(recovered).resolve(false);
106
+ verifyNoMoreInteractions(promise, recovered);
107
+ }
108
+
109
+ @Test
110
+ public void doesNotConsultOrMutateCorePermissionHistory() throws Exception {
111
+ try (org.mockito.MockedStatic<app.notifee.core.Notifee> core =
112
+ mockStatic(app.notifee.core.Notifee.class)) {
113
+ query(promise);
114
+ core.verifyNoInteractions();
115
+ }
116
+ }
117
+
118
+ @Test
119
+ public void currentActivityIsLookedUpAgainAfterLifecycleChange() throws Exception {
120
+ when(activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS))
121
+ .thenReturn(true);
122
+ query(promise);
123
+ when(context.getCurrentActivity()).thenReturn(null);
124
+ Promise unavailable = mock(Promise.class);
125
+ query(unavailable);
126
+ Activity replacement = mock(Activity.class);
127
+ when(context.getCurrentActivity()).thenReturn(replacement);
128
+ Promise refreshed = mock(Promise.class);
129
+ query(refreshed);
130
+ verify(refreshed).resolve(false);
131
+ if (Build.VERSION.SDK_INT >= 33) {
132
+ verify(promise).resolve(true);
133
+ verify(unavailable)
134
+ .reject(
135
+ "ERR_NO_ACTIVITY",
136
+ "A current Activity is required to query notification permission rationale.");
137
+ verify(replacement)
138
+ .shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS);
139
+ } else {
140
+ verify(promise).resolve(false);
141
+ verify(unavailable).resolve(false);
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,102 @@
1
+ package io.invertase.notifee;
2
+
3
+ import static org.junit.Assert.assertSame;
4
+ import static org.mockito.AdditionalMatchers.aryEq;
5
+ import static org.mockito.ArgumentMatchers.any;
6
+ import static org.mockito.ArgumentMatchers.anyInt;
7
+ import static org.mockito.ArgumentMatchers.eq;
8
+ import static org.mockito.ArgumentMatchers.same;
9
+ import static org.mockito.Mockito.doAnswer;
10
+ import static org.mockito.Mockito.doThrow;
11
+ import static org.mockito.Mockito.mock;
12
+ import static org.mockito.Mockito.never;
13
+ import static org.mockito.Mockito.verify;
14
+ import static org.mockito.Mockito.verifyNoMoreInteractions;
15
+ import static org.mockito.Mockito.when;
16
+ import static org.mockito.Mockito.withSettings;
17
+
18
+ import android.Manifest;
19
+ import android.app.Activity;
20
+ import android.os.Looper;
21
+ import app.notifee.core.Notifee;
22
+ import com.facebook.react.bridge.Promise;
23
+ import com.facebook.react.bridge.ReactApplicationContext;
24
+ import com.facebook.react.bridge.ReadableMap;
25
+ import com.facebook.react.modules.core.PermissionAwareActivity;
26
+ import java.util.concurrent.atomic.AtomicReference;
27
+ import org.junit.Before;
28
+ import org.junit.Test;
29
+ import org.junit.runner.RunWith;
30
+ import org.robolectric.RobolectricTestRunner;
31
+ import org.robolectric.Shadows;
32
+ import org.robolectric.annotation.Config;
33
+ import org.robolectric.annotation.LooperMode;
34
+
35
+ /** TurboModule promise methods run on the native modules thread, not on the main thread. */
36
+ @RunWith(RobolectricTestRunner.class)
37
+ @Config(
38
+ manifest = Config.NONE,
39
+ sdk = {33, 36})
40
+ @LooperMode(LooperMode.Mode.PAUSED)
41
+ public class RequestPermissionThreadTest {
42
+ private static final String[] POST_NOTIFICATIONS = {Manifest.permission.POST_NOTIFICATIONS};
43
+
44
+ private PermissionAwareActivity activity;
45
+ private NotifeeApiModule module;
46
+ private ReadableMap permissions;
47
+ private Promise promise;
48
+
49
+ @Before
50
+ public void setUp() {
51
+ ReactApplicationContext context = mock(ReactApplicationContext.class);
52
+ Activity currentActivity =
53
+ mock(Activity.class, withSettings().extraInterfaces(PermissionAwareActivity.class));
54
+ activity = (PermissionAwareActivity) currentActivity;
55
+ when(context.getCurrentActivity()).thenReturn(currentActivity);
56
+ module = new NotifeeApiModule(context);
57
+ permissions = mock(ReadableMap.class);
58
+ promise = mock(Promise.class);
59
+ }
60
+
61
+ private void requestFromModulesThread() throws InterruptedException {
62
+ Thread modulesThread = new Thread(() -> module.requestPermission(permissions, promise));
63
+ modulesThread.start();
64
+ modulesThread.join();
65
+ }
66
+
67
+ @Test
68
+ public void startsThePermissionActivityOnTheMainThread() throws Exception {
69
+ AtomicReference<Thread> requestThread = new AtomicReference<>();
70
+ doAnswer(
71
+ invocation -> {
72
+ requestThread.set(Thread.currentThread());
73
+ return null;
74
+ })
75
+ .when(activity)
76
+ .requestPermissions(any(), anyInt(), any());
77
+
78
+ requestFromModulesThread();
79
+ verify(activity, never()).requestPermissions(any(), anyInt(), any());
80
+
81
+ Shadows.shadowOf(Looper.getMainLooper()).idle();
82
+ verify(activity)
83
+ .requestPermissions(
84
+ aryEq(POST_NOTIFICATIONS),
85
+ eq(Notifee.REQUEST_CODE_NOTIFICATION_PERMISSION),
86
+ same(module));
87
+ assertSame(Looper.getMainLooper().getThread(), requestThread.get());
88
+ verifyNoMoreInteractions(promise);
89
+ }
90
+
91
+ @Test
92
+ public void aRequestThatThrowsOnTheMainThreadStillRejects() throws Exception {
93
+ IllegalStateException failure = new IllegalStateException("activity is finishing");
94
+ doThrow(failure).when(activity).requestPermissions(any(), anyInt(), any());
95
+
96
+ requestFromModulesThread();
97
+ Shadows.shadowOf(Looper.getMainLooper()).idle();
98
+
99
+ verify(promise).reject(failure);
100
+ verifyNoMoreInteractions(promise);
101
+ }
102
+ }
@@ -94,6 +94,17 @@ async function initNse(opts) {
94
94
  ' Use --force to overwrite or --target-name to use a different name.');
95
95
  process.exit(1);
96
96
  }
97
+ // Validate all Podfile changes before --force can overwrite consumer NSE
98
+ // templates, and before a dry run reports a configuration as repairable.
99
+ const podfilePath = path.join(projectInfo.iosDir, 'Podfile');
100
+ try {
101
+ if (fs.existsSync(podfilePath))
102
+ (0, patchPodfile_1.patchPodfile)(podfilePath, targetName, true);
103
+ }
104
+ catch (e) {
105
+ logger.error(`Failed during NSE Podfile preflight: ${e instanceof Error ? e.message : String(e)}`);
106
+ return process.exit(2);
107
+ }
97
108
  if (dryRun) {
98
109
  logger.info('[DRY RUN] Would perform the following actions:');
99
110
  logger.info(` iOS project: ${absIosDir}`);
@@ -107,7 +118,6 @@ async function initNse(opts) {
107
118
  return;
108
119
  }
109
120
  // 4. Create backups (PID-stamped to avoid collision with parallel runs)
110
- const podfilePath = path.join(projectInfo.iosDir, 'Podfile');
111
121
  const backups = [];
112
122
  const templateDirCreated = !dirExists; // Track if WE created the dir (for rollback)
113
123
  const bakSuffix = `.bak.${process.pid}.${Date.now()}`;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureNotifeeExtensionFlag = ensureNotifeeExtensionFlag;
4
+ const child_process_1 = require("child_process");
5
+ // Keep the Expo/CLI copies identical: each is shipped through a separate build.
6
+ // Parse, never execute, consumer Ruby. A line scanner cannot distinguish a
7
+ // top-level assignment from continuations, interpolation, heredocs or blocks.
8
+ const FLAG_PREFLIGHT = String.raw `
9
+ require 'ripper'
10
+ require 'json'
11
+ source = STDIN.read
12
+ ast = Ripper.sexp(source)
13
+ def walk(node, &block)
14
+ return unless node.is_a?(Array)
15
+ yield node
16
+ node.each { |child| walk(child, &block) if child.is_a?(Array) }
17
+ end
18
+ def flag_tokens(node)
19
+ tokens = []
20
+ walk(node) { |part| tokens << part if part[0] == :@gvar && part[1] == '$NotifeeExtension' }
21
+ tokens
22
+ end
23
+ def evaluation?(node)
24
+ found = false
25
+ walk(node) do |part|
26
+ if [:@ident, :@const].include?(part[0]) &&
27
+ %w[target abstract_target pod podspec use_native_modules! use_expo_modules! use_react_native! Specification eval instance_eval class_eval module_eval from_file from_string].include?(part[1])
28
+ found = true
29
+ end
30
+ found = true if part[0] == :@tstring_content && part[1].include?('.podspec')
31
+ end
32
+ found
33
+ end
34
+ # Only a bounded prelude is provably safe before an existing true flag. Unknown
35
+ # helpers and dynamic dispatch can evaluate podspecs even without a named call.
36
+ def literal?(node)
37
+ return true if node.nil? || node == false
38
+ return false unless node.is_a?(Array)
39
+ return node.all? { |part| literal?(part) } unless node[0].is_a?(Symbol)
40
+ case node[0]
41
+ when :@int, :@float, :@rational, :@imaginary, :@tstring_content, :@label, :@ident
42
+ true
43
+ when :@kw
44
+ %w[true false nil].include?(node[1])
45
+ when :var_ref, :string_literal, :string_content, :symbol_literal, :symbol,
46
+ :array, :hash, :assoclist_from_args, :assoc_new, :args_add_block, :arg_paren
47
+ node.drop(1).all? { |part| literal?(part) }
48
+ else
49
+ false
50
+ end
51
+ end
52
+ def safe_prelude?(node)
53
+ return true if node[0] == :void_stmt
54
+ if node[0] == :assign
55
+ lhs = node[1]
56
+ return lhs && lhs[0] == :var_field &&
57
+ [:@ident, :@gvar, :@ivar, :@cvar, :@const].include?(lhs[1][0]) && literal?(node[2])
58
+ end
59
+ allowed = %w[require require_relative platform source project workspace install! use_frameworks!]
60
+ if node[0] == :command
61
+ return allowed.include?(node[1][1]) && literal?(node[2])
62
+ end
63
+ if node[0] == :method_add_arg && node[1][0] == :fcall
64
+ return allowed.include?(node[1][1][1]) && literal?(node[2])
65
+ end
66
+ false
67
+ end
68
+ unless ast
69
+ puts JSON.generate({ error: 'Ruby syntax could not be parsed', line: 1 })
70
+ exit
71
+ end
72
+ has_eval = false
73
+ flag_text = nil
74
+ walk(ast) do |part|
75
+ has_eval = true if part[0] == :@ident && %w[eval instance_eval class_eval module_eval].include?(part[1])
76
+ flag_text ||= part if part[0] == :@tstring_content && part[1].include?('$NotifeeExtension')
77
+ end
78
+ if has_eval && flag_text
79
+ puts JSON.generate({ error: 'dynamic Ruby evaluation mentions the flag', line: flag_text[2][0] })
80
+ exit
81
+ end
82
+ all = flag_tokens(ast)
83
+ accepted = []
84
+ evaluating = false
85
+ error = nil
86
+ (ast[1] || []).each do |statement|
87
+ tokens = flag_tokens(statement)
88
+ unless tokens.empty?
89
+ lhs = statement[1] if statement[0] == :assign
90
+ rhs = statement[2] if statement[0] == :assign
91
+ literal_true = lhs && lhs[0] == :var_field && lhs[1][0] == :@gvar &&
92
+ lhs[1][1] == '$NotifeeExtension' && rhs && rhs[0] == :var_ref && rhs[1][0] == :@kw && rhs[1][1] == 'true'
93
+ if !literal_true || tokens.length != 1
94
+ error = { error: 'conflicting or ambiguous configuration; only unconditional literal true is supported', line: tokens[0][2][0] }
95
+ break
96
+ end
97
+ if evaluating && accepted.empty?
98
+ error = { error: 'assignment follows possible target/autolinking or podspec evaluation', line: tokens[0][2][0] }
99
+ break
100
+ end
101
+ accepted.concat(tokens)
102
+ end
103
+ evaluating ||= evaluation?(statement) || !safe_prelude?(statement)
104
+ end
105
+ puts JSON.generate(error || { assigned: !accepted.empty?, references: all.length })
106
+ `;
107
+ function ensureNotifeeExtensionFlag(contents) {
108
+ if (contents.includes('$NotifeeExtension')) {
109
+ let result;
110
+ try {
111
+ result = JSON.parse((0, child_process_1.execFileSync)('ruby', ['-e', FLAG_PREFLIGHT], {
112
+ input: contents,
113
+ encoding: 'utf8',
114
+ timeout: 10_000,
115
+ maxBuffer: 16 * 1024 * 1024,
116
+ }));
117
+ }
118
+ catch {
119
+ throw new Error('NotifyKit NSE: $NotifeeExtension at line 1: Ruby (Ripper) preflight unavailable. ' +
120
+ 'Use the Ruby toolchain required by CocoaPods; no changes were written.');
121
+ }
122
+ if (result.error) {
123
+ throw new Error(`NotifyKit NSE: $NotifeeExtension at line ${result.line}: ${result.error}. ` +
124
+ 'Set $NotifeeExtension = true in the Podfile header before autolinking; no changes were written.');
125
+ }
126
+ if (result.assigned)
127
+ return contents;
128
+ }
129
+ // Insert after Ruby's shebang, magic comments and other leading comments.
130
+ // Keep every original byte, including a BOM and a header without final LF.
131
+ const bom = contents.startsWith('\uFEFF') ? '\uFEFF' : '';
132
+ const text = contents.slice(bom.length);
133
+ const lines = text.split('\n');
134
+ let headerEnd = 0;
135
+ let blockComment = false;
136
+ for (let index = 0; index < lines.length; index++) {
137
+ const line = lines[index];
138
+ if (/^=begin(?:\s|$)/.test(line))
139
+ blockComment = true;
140
+ if (!blockComment && !/^\s*(?:#.*)?$/.test(line))
141
+ break;
142
+ headerEnd += line.length + (index < lines.length - 1 ? 1 : 0);
143
+ if (/^=end(?:\s|$)/.test(line))
144
+ blockComment = false;
145
+ }
146
+ const separator = headerEnd > 0 && text[headerEnd - 1] !== '\n' ? '\n' : '';
147
+ return (bom +
148
+ text.slice(0, headerEnd) +
149
+ separator +
150
+ '$NotifeeExtension = true\n' +
151
+ text.slice(headerEnd));
152
+ }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.patchPodfile = patchPodfile;
37
37
  exports.patchPodfileForNotifyKitNse = patchPodfileForNotifyKitNse;
38
38
  exports.getPatchedPodfile = getPatchedPodfile;
39
+ const notifeeExtensionFlag_1 = require("./notifeeExtensionFlag");
39
40
  const fs = __importStar(require("fs"));
40
41
  const DEFAULT_PACKAGE_PATH_FROM_IOS = '../node_modules/react-native-notify-kit';
41
42
  const RNFB_INFO_PLIST_INPUT_PATH = '$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)';
@@ -60,8 +61,8 @@ function patchPodfile(podfilePath, targetName, dryRun) {
60
61
  return true;
61
62
  }
62
63
  function patchPodfileForNotifyKitNse(podfileText, options) {
63
- let patched = podfileText;
64
- let changed = false;
64
+ let patched = (0, notifeeExtensionFlag_1.ensureNotifeeExtensionFlag)(podfileText);
65
+ let changed = patched !== podfileText;
65
66
  const packagePathFromIos = options.packagePathFromIos ?? DEFAULT_PACKAGE_PATH_FROM_IOS;
66
67
  // Idempotency check — skip commented lines (# prefix)
67
68
  if (!hasUncommentedTarget(patched, options.targetName)) {
package/cli/package.json CHANGED
@@ -1 +1 @@
1
- {"version":"10.9.1"}
1
+ {"version":"10.10.1"}
@@ -36,6 +36,7 @@ export default class NotifeeApiModule extends NotifeeNativeModule implements Mod
36
36
  onBackgroundEvent: (observer: (event: Event) => Promise<void>) => void;
37
37
  onForegroundEvent: (observer: (event: Event) => void) => (() => void);
38
38
  openNotificationSettings: (channelId?: string) => Promise<void>;
39
+ shouldShowRequestPermissionRationale: () => Promise<boolean>;
39
40
  requestPermission: (permissions?: IOSNotificationPermissions) => Promise<NotificationSettings>;
40
41
  registerForegroundService(runner: (notification: Notification) => Promise<void>): void;
41
42
  setNotificationCategories: (categories: IOSNotificationCategory[]) => Promise<void>;
@@ -373,6 +373,12 @@ export default class NotifeeApiModule extends NotifeeNativeModule {
373
373
  }
374
374
  return Promise.resolve();
375
375
  };
376
+ shouldShowRequestPermissionRationale = () => {
377
+ if (isAndroid) {
378
+ return this.native.shouldShowRequestPermissionRationale();
379
+ }
380
+ return Promise.resolve(false);
381
+ };
376
382
  requestPermission = (permissions = {}) => {
377
383
  if (isAndroid) {
378
384
  return this.native.requestPermission({}).then(({ authorizationStatus, android, }) => {