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 +25 -1
- package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +40 -12
- package/android/src/test/java/io/invertase/notifee/PermissionRationaleTest.java +144 -0
- package/android/src/test/java/io/invertase/notifee/RequestPermissionThreadTest.java +102 -0
- package/cli/dist/commands/initNse.js +11 -1
- package/cli/dist/lib/notifeeExtensionFlag.js +152 -0
- package/cli/dist/lib/patchPodfile.js +3 -2
- package/cli/package.json +1 -1
- package/dist/NotifeeApiModule.d.ts +1 -0
- package/dist/NotifeeApiModule.js +6 -0
- package/dist/NotifeeApiModule.js.map +1 -1
- package/dist/specs/NativeNotifeeModule.d.ts +1 -0
- package/dist/specs/NativeNotifeeModule.js.map +1 -1
- package/dist/types/Module.d.ts +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +128 -72
- package/ios/RNNotifee/NotifeeApiModule.mm +5 -0
- package/jest-mock.js +1 -0
- package/package.json +4 -3
- package/plugin/build/ios/withNotifyKitIosNseFiles.js +43 -7
- package/plugin/build/ios/withNotifyKitIosNsePodfile.js +19 -77
- package/plugin/build/shared/nse/notifeeExtensionFlag.js +155 -0
- package/plugin/build/shared/nse/patchPodfile.js +6 -64
- package/src/NotifeeApiModule.ts +8 -0
- package/src/specs/NativeNotifeeModule.ts +1 -0
- package/src/types/Module.ts +20 -0
- package/src/version.ts +1 -1
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
|
-
- **
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 =
|
|
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.
|
|
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>;
|
package/dist/NotifeeApiModule.js
CHANGED
|
@@ -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, }) => {
|