react-native-notify-kit 10.10.0 → 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,8 @@ 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
+
58
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.
59
61
 
60
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.
@@ -425,6 +427,11 @@ cd ios && pod install
425
427
 
426
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`).
427
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
+
428
435
  ### Manual setup
429
436
 
430
437
  For bare React Native projects with non-standard iOS paths or heavily-customized Xcode configurations where automation cannot patch the project cleanly:
@@ -534,7 +541,7 @@ This fork is a complete migration to React Native's **New Architecture**:
534
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`.
535
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`.
536
543
  - **Core notification logic (NotifeeCore) is unchanged** — the public API is fully compatible with the original Notifee
537
- - **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
538
545
  - **Reliable trigger notifications** — AlarmManager is the default backend instead of WorkManager, with automatic fallback when exact alarm permission is not granted
539
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).
540
547
  - **New API: `setNotificationConfig()`** — opt-out flag to prevent Notifee from intercepting iOS remote notification handlers (see [New APIs](#new-apis) below)
@@ -584,6 +591,7 @@ This fork fixes the following bugs that were never resolved in the original Noti
584
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 |
585
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 |
586
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 |
587
595
 
588
596
  > **Important note on `getDisplayedNotifications()` and FCM custom data on Android.**
589
597
  >
@@ -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
 
@@ -284,18 +285,22 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
284
285
  NotifeeReactUtils.promiseResolver(promise, e, bundle)
285
286
  }
286
287
 
287
- try {
288
- activity.requestPermissions(
289
- arrayOf(Manifest.permission.POST_NOTIFICATIONS),
290
- Notifee.REQUEST_CODE_NOTIFICATION_PERMISSION,
291
- this,
292
- )
293
- } catch (e: Exception) {
294
- Logger.d(
295
- "requestPermission",
296
- "Failed to request POST_NOTIFICATIONS permission: ${e.message}",
297
- )
298
- 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
+ }
299
304
  }
300
305
  }
301
306
 
@@ -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.10.0"}
1
+ {"version":"10.10.1"}
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "10.10.0";
1
+ export declare const version = "10.10.1";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by genversion.
2
- export const version = '10.10.0';
2
+ export const version = '10.10.1';
3
3
  //# sourceMappingURL=version.js.map