From b3a4db6b67c5d25d5c523e8b77d7dba5b327c5b2 Mon Sep 17 00:00:00 2001 From: Juha Linnanen Date: Fri, 11 Sep 2026 17:14:47 +0300 Subject: [PATCH] feat(Linking): add openNotificationSettings() Add `Linking.openNotificationSettings()` which opens the app's notification settings screen in the system Settings app. - iOS: uses `UIApplicationOpenNotificationSettingsURLString` (iOS 15.4+), falling back to `UIApplicationOpenSettingsURLString` on 15.1-15.3. - Android: launches `Settings.ACTION_APP_NOTIFICATION_SETTINGS` with `Settings.EXTRA_APP_PACKAGE` (API 26+), falling back to `ACTION_APPLICATION_DETAILS_SETTINGS` on API 24-25. Includes TurboModule spec entries, legacy and generated TS types, ReactAndroid.api entry, jest-preset mock, RNTester example, a JS dispatch test and Robolectric tests for the Android module. Changelog: [GENERAL] [ADDED] - Add Linking.openNotificationSettings() to open the app's notification settings on iOS (15.4+) and Android (API 26+), falling back to the app settings page on older OS versions Co-Authored-By: Claude Fable 5.1 --- packages/jest-preset/jest/mocks/Linking.js | 1 + .../react-native/Libraries/Linking/Linking.js | 16 ++++ .../Linking/__tests__/Linking-test.js | 90 +++++++++++++++++++ .../Libraries/LinkingIOS/RCTLinkingManager.mm | 31 ++++++- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/modules/intent/IntentModule.kt | 53 ++++++++--- .../react/modules/intent/IntentModuleTest.kt | 79 ++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 5 +- .../modules/NativeIntentAndroid.js | 1 + .../modules/NativeLinkingManager.js | 1 + .../Libraries/Linking/Linking.d.ts | 6 ++ .../js/examples/Linking/LinkingExample.js | 23 +++++ 12 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 packages/react-native/Libraries/Linking/__tests__/Linking-test.js diff --git a/packages/jest-preset/jest/mocks/Linking.js b/packages/jest-preset/jest/mocks/Linking.js index c558c824a149..e4fc118d96b9 100644 --- a/packages/jest-preset/jest/mocks/Linking.js +++ b/packages/jest-preset/jest/mocks/Linking.js @@ -15,6 +15,7 @@ const Linking = { $FlowFixMe, >, openSettings: jest.fn() as JestMockFn<$FlowFixMe, $FlowFixMe>, + openNotificationSettings: jest.fn() as JestMockFn<$FlowFixMe, $FlowFixMe>, addEventListener: jest.fn(() => ({ remove: jest.fn(), })) as JestMockFn<$FlowFixMe, $FlowFixMe>, diff --git a/packages/react-native/Libraries/Linking/Linking.js b/packages/react-native/Libraries/Linking/Linking.js index 338365f681b6..8de120aeda7b 100644 --- a/packages/react-native/Libraries/Linking/Linking.js +++ b/packages/react-native/Libraries/Linking/Linking.js @@ -82,6 +82,22 @@ class LinkingImpl extends NativeEventEmitter { } } + /** + * Open the device Settings app and display the app’s notification settings. + * + * Uses `UIApplicationOpenNotificationSettingsURLString` on iOS 15.4 and + * later, and `Settings.ACTION_APP_NOTIFICATION_SETTINGS` on Android 8.0 + * (API 26) and later. On older OS versions it falls back to the app’s + * general settings page, the same screen that `openSettings()` opens. + */ + openNotificationSettings(): Promise { + if (Platform.OS === 'android') { + return nullthrows(NativeIntentAndroid).openNotificationSettings(); + } else { + return nullthrows(NativeLinkingManager).openNotificationSettings(); + } + } + /** * Get the URL that launched the app, or `null` if it was not launched from * a link. To support deep linking on Android, see diff --git a/packages/react-native/Libraries/Linking/__tests__/Linking-test.js b/packages/react-native/Libraries/Linking/__tests__/Linking-test.js new file mode 100644 index 000000000000..8b806a6c215a --- /dev/null +++ b/packages/react-native/Libraries/Linking/__tests__/Linking-test.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +jest.unmock('../Linking'); + +const mockNativeLinkingManager = { + openSettings: jest.fn(() => Promise.resolve()), + openNotificationSettings: jest.fn(() => Promise.resolve()), + addListener: jest.fn(), + removeListeners: jest.fn(), +}; + +const mockNativeIntentAndroid = { + openSettings: jest.fn(() => Promise.resolve()), + openNotificationSettings: jest.fn(() => Promise.resolve()), +}; + +jest.mock('../NativeLinkingManager', () => ({ + __esModule: true, + default: mockNativeLinkingManager, +})); + +jest.mock('../NativeIntentAndroid', () => ({ + __esModule: true, + default: mockNativeIntentAndroid, +})); + +function requireLinkingForPlatform(os: 'ios' | 'android') { + jest.resetModules(); + jest.doMock('../../Utilities/Platform', () => ({ + __esModule: true, + default: {OS: os, select: (obj: {[string]: unknown}) => obj[os]}, + })); + return require('../Linking').default; +} + +describe('Linking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('openNotificationSettings', () => { + it('calls NativeLinkingManager on iOS', async () => { + const Linking = requireLinkingForPlatform('ios'); + await Linking.openNotificationSettings(); + expect( + mockNativeLinkingManager.openNotificationSettings, + ).toHaveBeenCalledTimes(1); + expect( + mockNativeIntentAndroid.openNotificationSettings, + ).not.toHaveBeenCalled(); + }); + + it('calls NativeIntentAndroid on Android', async () => { + const Linking = requireLinkingForPlatform('android'); + await Linking.openNotificationSettings(); + expect( + mockNativeIntentAndroid.openNotificationSettings, + ).toHaveBeenCalledTimes(1); + expect( + mockNativeLinkingManager.openNotificationSettings, + ).not.toHaveBeenCalled(); + }); + }); + + describe('openSettings', () => { + it('calls NativeLinkingManager on iOS', async () => { + const Linking = requireLinkingForPlatform('ios'); + await Linking.openSettings(); + expect(mockNativeLinkingManager.openSettings).toHaveBeenCalledTimes(1); + expect(mockNativeIntentAndroid.openSettings).not.toHaveBeenCalled(); + }); + + it('calls NativeIntentAndroid on Android', async () => { + const Linking = requireLinkingForPlatform('android'); + await Linking.openSettings(); + expect(mockNativeIntentAndroid.openSettings).toHaveBeenCalledTimes(1); + expect(mockNativeLinkingManager.openSettings).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm index f25ad54a5f4a..47200a360cb2 100644 --- a/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm +++ b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm @@ -219,16 +219,41 @@ - (void)getInitialURL:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromis resolve(RCTNullIfNil(initialURL.absoluteString)); } -- (void)openSettings:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject +- (void)openSettings:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + [self openSettingsURLString:UIApplicationOpenSettingsURLString + errorMessage:@"Unable to open app settings" + resolve:resolve + reject:reject]; +} + +- (void)openNotificationSettings:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + // UIApplicationOpenNotificationSettingsURLString is available from iOS 15.4. + // On older versions fall back to the app's general settings page. + NSString *urlString = UIApplicationOpenSettingsURLString; + if (@available(iOS 15.4, *)) { + urlString = UIApplicationOpenNotificationSettingsURLString; + } + [self openSettingsURLString:urlString + errorMessage:@"Unable to open app notification settings" + resolve:resolve + reject:reject]; +} + +- (void)openSettingsURLString:(NSString *)urlString + errorMessage:(NSString *)errorMessage + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSURL *url = [NSURL URLWithString:urlString]; [RCTSharedApplication() openURL:url options:@{} completionHandler:^(BOOL success) { if (success) { resolve(nil); } else { - reject(RCTErrorUnspecified, @"Unable to open app settings", nil); + reject(RCTErrorUnspecified, errorMessage, nil); } }]; } diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 612bdd1d6f32..749e1e723183 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -2779,6 +2779,7 @@ public class com/facebook/react/modules/intent/IntentModule : com/facebook/fbrea public fun canOpenURL (Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V public fun getInitialURL (Lcom/facebook/react/bridge/Promise;)V public fun invalidate ()V + public fun openNotificationSettings (Lcom/facebook/react/bridge/Promise;)V public fun openSettings (Lcom/facebook/react/bridge/Promise;)V public fun openURL (Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V public fun sendIntent (Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;Lcom/facebook/react/bridge/Promise;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt index d3338be3ddc6..2e4b644644d1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt @@ -11,6 +11,7 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter +import android.os.Build import android.provider.Settings import com.facebook.fbreact.specs.NativeIntentAndroidSpec import com.facebook.react.bridge.JSApplicationIllegalArgumentException @@ -168,18 +169,7 @@ public open class IntentModule(reactContext: ReactApplicationContext) : */ override fun openSettings(promise: Promise) { try { - val intent = Intent() - val currentActivity: Activity = checkNotNull(reactApplicationContext.getCurrentActivity()) - val selfPackageName = reactApplicationContext.packageName - - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.addCategory(Intent.CATEGORY_DEFAULT) - intent.setData(Uri.parse("package:$selfPackageName")) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - currentActivity.startActivity(intent) - + startSettingsActivity(appDetailsSettingsIntent()) promise.resolve(true) } catch (e: Exception) { promise.reject( @@ -188,6 +178,45 @@ public open class IntentModule(reactContext: ReactApplicationContext) : } } + /** + * Starts an external activity to open the app's notification settings into Android Settings. + * On Android versions before 8.0 (API 26) this falls back to the app's details settings screen. + * + * @param promise a promise which is resolved when the Settings is opened + */ + override fun openNotificationSettings(promise: Promise) { + try { + val intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, reactApplicationContext.packageName) + } else { + appDetailsSettingsIntent() + } + startSettingsActivity(intent) + promise.resolve(null) + } catch (e: Exception) { + promise.reject( + JSApplicationIllegalArgumentException( + "Could not open the notification Settings: ${e.message}", + ), + ) + } + } + + private fun appDetailsSettingsIntent(): Intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .addCategory(Intent.CATEGORY_DEFAULT) + .setData(Uri.parse("package:${reactApplicationContext.packageName}")) + + private fun startSettingsActivity(intent: Intent) { + val currentActivity: Activity = checkNotNull(reactApplicationContext.getCurrentActivity()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + currentActivity.startActivity(intent) + } + /** * Allows to send intents on Android * diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt index 6528449969bd..769bff5ea2e1 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt @@ -7,6 +7,9 @@ package com.facebook.react.modules.intent +import android.app.Activity +import android.content.Intent +import android.provider.Settings import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -19,10 +22,12 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class IntentModuleTest { @@ -65,6 +70,80 @@ class IntentModuleTest { assertThat(promise.rejected).isEqualTo(0) } + @Test + fun openNotificationSettings_startsAppNotificationSettingsIntent() { + val activity = mock() + whenever(context.currentActivity).thenReturn(activity) + whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME) + + val promise = SimplePromise() + intentModule.openNotificationSettings(promise) + + val intentCaptor = argumentCaptor() + verify(activity).startActivity(intentCaptor.capture()) + val intent = intentCaptor.firstValue + assertThat(intent.action).isEqualTo(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(TEST_PACKAGE_NAME) + assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0) + assertThat(promise.resolved).isEqualTo(1) + assertThat(promise.rejected).isEqualTo(0) + } + + @Test + @Config(sdk = [25]) + fun openNotificationSettings_beforeApi26_fallsBackToAppDetailsSettings() { + val activity = mock() + whenever(context.currentActivity).thenReturn(activity) + whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME) + + val promise = SimplePromise() + intentModule.openNotificationSettings(promise) + + val intentCaptor = argumentCaptor() + verify(activity).startActivity(intentCaptor.capture()) + val intent = intentCaptor.firstValue + assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + assertThat(intent.data.toString()).isEqualTo("package:$TEST_PACKAGE_NAME") + assertThat(promise.resolved).isEqualTo(1) + assertThat(promise.rejected).isEqualTo(0) + } + + @Test + fun openNotificationSettings_withoutActivity_rejects() { + whenever(context.currentActivity).thenReturn(null) + whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME) + + val promise = SimplePromise() + intentModule.openNotificationSettings(promise) + + verify(context, never()).startActivity(any()) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.rejected).isEqualTo(1) + } + + @Test + fun openSettings_startsAppDetailsSettingsIntent() { + val activity = mock() + whenever(context.currentActivity).thenReturn(activity) + whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME) + + val promise = SimplePromise() + intentModule.openSettings(promise) + + val intentCaptor = argumentCaptor() + verify(activity).startActivity(intentCaptor.capture()) + val intent = intentCaptor.firstValue + assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + assertThat(intent.data.toString()).isEqualTo("package:$TEST_PACKAGE_NAME") + assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0) + assertThat(promise.resolved).isEqualTo(1) + assertThat(promise.rejected).isEqualTo(0) + } + + private companion object { + const val TEST_PACKAGE_NAME = "com.facebook.react.uiapp" + } + internal class SimplePromise : Promise { companion object { private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED" diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 429f8506e441..a6b9de6efebd 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<1f73b02dee445f4054d04e94db78f78b>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -2872,6 +2872,7 @@ declare class LinkingImpl extends NativeEventEmitter { canOpenURL(url: string): Promise constructor() getInitialURL(): Promise + openNotificationSettings(): Promise openSettings(): Promise openURL(url: string): Promise sendIntent( @@ -5882,7 +5883,7 @@ export { LayoutChangeEvent, // 98960b70 LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 - Linking, // 9a6a174d + Linking, // e5dcc78b ListRenderItem, // b5353fd8 ListRenderItemInfo, // e8595b03 ListViewToken, // 833d3481 diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeIntentAndroid.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeIntentAndroid.js index cc1f752d66f4..d00449396c6e 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeIntentAndroid.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeIntentAndroid.js @@ -17,6 +17,7 @@ export interface Spec extends TurboModule { readonly canOpenURL: (url: string) => Promise; readonly openURL: (url: string) => Promise; readonly openSettings: () => Promise; + readonly openNotificationSettings: () => Promise; readonly sendIntent: ( action: string, extras: ?Array<{ diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js index fab06fc992f7..ba8f63bcebf5 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js @@ -18,6 +18,7 @@ export interface Spec extends TurboModule { readonly canOpenURL: (url: string) => Promise; readonly openURL: (url: string) => Promise; readonly openSettings: () => Promise; + readonly openNotificationSettings: () => Promise; // Events readonly addListener: (eventName: string) => void; diff --git a/packages/react-native/types_DEPRECATED/Libraries/Linking/Linking.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Linking/Linking.d.ts index 55d7aa33cb58..5006c9642533 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Linking/Linking.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Linking/Linking.d.ts @@ -47,6 +47,12 @@ export interface LinkingImpl extends NativeEventEmitter { */ openSettings(): Promise; + /** + * Open the Settings app and displays the app’s notification settings. + * Falls back to the app’s general settings page on iOS < 15.4 and Android < 8.0 (API 26). + */ + openNotificationSettings(): Promise; + /** * Sends an Android Intent - a broad surface to express Android functions. Useful for deep-linking to settings pages, * opening an SMS app with a message draft in place, and more. See https://developer.android.com/reference/kotlin/android/content/Intent?hl=en diff --git a/packages/rn-tester/js/examples/Linking/LinkingExample.js b/packages/rn-tester/js/examples/Linking/LinkingExample.js index 245136b3be87..b97d920147ef 100644 --- a/packages/rn-tester/js/examples/Linking/LinkingExample.js +++ b/packages/rn-tester/js/examples/Linking/LinkingExample.js @@ -65,6 +65,21 @@ class OpenSettingsExample extends React.Component> { } } +class OpenNotificationSettingsExample extends React.Component> { + openNotificationSettings = () => { + void Linking.openNotificationSettings(); + }; + + render(): React.Node { + return ( +