Skip to content

Commit 47bb430

Browse files
plrdevclaude
authored andcommitted
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 <noreply@anthropic.com>
1 parent af4d8eb commit 47bb430

12 files changed

Lines changed: 290 additions & 17 deletions

File tree

‎packages/jest-preset/jest/mocks/Linking.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const Linking = {
1515
$FlowFixMe,
1616
>,
1717
openSettings: jest.fn() as JestMockFn<$FlowFixMe, $FlowFixMe>,
18+
openNotificationSettings: jest.fn() as JestMockFn<$FlowFixMe, $FlowFixMe>,
1819
addEventListener: jest.fn(() => ({
1920
remove: jest.fn(),
2021
})) as JestMockFn<$FlowFixMe, $FlowFixMe>,

‎packages/react-native/Libraries/Linking/Linking.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ class LinkingImpl extends NativeEventEmitter<LinkingEventDefinitions> {
8282
}
8383
}
8484

85+
/**
86+
* Open the device Settings app and display the app’s notification settings.
87+
*
88+
* Uses `UIApplicationOpenNotificationSettingsURLString` on iOS 15.4 and
89+
* later, and `Settings.ACTION_APP_NOTIFICATION_SETTINGS` on Android 8.0
90+
* (API 26) and later. On older OS versions it falls back to the app’s
91+
* general settings page, the same screen that `openSettings()` opens.
92+
*/
93+
openNotificationSettings(): Promise<void> {
94+
if (Platform.OS === 'android') {
95+
return nullthrows(NativeIntentAndroid).openNotificationSettings();
96+
} else {
97+
return nullthrows(NativeLinkingManager).openNotificationSettings();
98+
}
99+
}
100+
85101
/**
86102
* Get the URL that launched the app, or `null` if it was not launched from
87103
* a link. To support deep linking on Android, see
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
jest.unmock('../Linking');
14+
15+
const mockNativeLinkingManager = {
16+
openSettings: jest.fn(() => Promise.resolve()),
17+
openNotificationSettings: jest.fn(() => Promise.resolve()),
18+
addListener: jest.fn(),
19+
removeListeners: jest.fn(),
20+
};
21+
22+
const mockNativeIntentAndroid = {
23+
openSettings: jest.fn(() => Promise.resolve()),
24+
openNotificationSettings: jest.fn(() => Promise.resolve()),
25+
};
26+
27+
jest.mock('../NativeLinkingManager', () => ({
28+
__esModule: true,
29+
default: mockNativeLinkingManager,
30+
}));
31+
32+
jest.mock('../NativeIntentAndroid', () => ({
33+
__esModule: true,
34+
default: mockNativeIntentAndroid,
35+
}));
36+
37+
function requireLinkingForPlatform(os: 'ios' | 'android') {
38+
jest.resetModules();
39+
jest.doMock('../../Utilities/Platform', () => ({
40+
__esModule: true,
41+
default: {OS: os, select: (obj: {[string]: unknown}) => obj[os]},
42+
}));
43+
return require('../Linking').default;
44+
}
45+
46+
describe('Linking', () => {
47+
beforeEach(() => {
48+
jest.clearAllMocks();
49+
});
50+
51+
describe('openNotificationSettings', () => {
52+
it('calls NativeLinkingManager on iOS', async () => {
53+
const Linking = requireLinkingForPlatform('ios');
54+
await Linking.openNotificationSettings();
55+
expect(
56+
mockNativeLinkingManager.openNotificationSettings,
57+
).toHaveBeenCalledTimes(1);
58+
expect(
59+
mockNativeIntentAndroid.openNotificationSettings,
60+
).not.toHaveBeenCalled();
61+
});
62+
63+
it('calls NativeIntentAndroid on Android', async () => {
64+
const Linking = requireLinkingForPlatform('android');
65+
await Linking.openNotificationSettings();
66+
expect(
67+
mockNativeIntentAndroid.openNotificationSettings,
68+
).toHaveBeenCalledTimes(1);
69+
expect(
70+
mockNativeLinkingManager.openNotificationSettings,
71+
).not.toHaveBeenCalled();
72+
});
73+
});
74+
75+
describe('openSettings', () => {
76+
it('calls NativeLinkingManager on iOS', async () => {
77+
const Linking = requireLinkingForPlatform('ios');
78+
await Linking.openSettings();
79+
expect(mockNativeLinkingManager.openSettings).toHaveBeenCalledTimes(1);
80+
expect(mockNativeIntentAndroid.openSettings).not.toHaveBeenCalled();
81+
});
82+
83+
it('calls NativeIntentAndroid on Android', async () => {
84+
const Linking = requireLinkingForPlatform('android');
85+
await Linking.openSettings();
86+
expect(mockNativeIntentAndroid.openSettings).toHaveBeenCalledTimes(1);
87+
expect(mockNativeLinkingManager.openSettings).not.toHaveBeenCalled();
88+
});
89+
});
90+
});

‎packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,16 +219,41 @@ - (void)getInitialURL:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromis
219219
resolve(RCTNullIfNil(initialURL.absoluteString));
220220
}
221221

222-
- (void)openSettings:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject
222+
- (void)openSettings:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
223223
{
224-
NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
224+
[self openSettingsURLString:UIApplicationOpenSettingsURLString
225+
errorMessage:@"Unable to open app settings"
226+
resolve:resolve
227+
reject:reject];
228+
}
229+
230+
- (void)openNotificationSettings:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
231+
{
232+
// UIApplicationOpenNotificationSettingsURLString is available from iOS 15.4.
233+
// On older versions fall back to the app's general settings page.
234+
NSString *urlString = UIApplicationOpenSettingsURLString;
235+
if (@available(iOS 15.4, *)) {
236+
urlString = UIApplicationOpenNotificationSettingsURLString;
237+
}
238+
[self openSettingsURLString:urlString
239+
errorMessage:@"Unable to open app notification settings"
240+
resolve:resolve
241+
reject:reject];
242+
}
243+
244+
- (void)openSettingsURLString:(NSString *)urlString
245+
errorMessage:(NSString *)errorMessage
246+
resolve:(RCTPromiseResolveBlock)resolve
247+
reject:(RCTPromiseRejectBlock)reject
248+
{
249+
NSURL *url = [NSURL URLWithString:urlString];
225250
[RCTSharedApplication() openURL:url
226251
options:@{}
227252
completionHandler:^(BOOL success) {
228253
if (success) {
229254
resolve(nil);
230255
} else {
231-
reject(RCTErrorUnspecified, @"Unable to open app settings", nil);
256+
reject(RCTErrorUnspecified, errorMessage, nil);
232257
}
233258
}];
234259
}

‎packages/react-native/ReactAndroid/api/ReactAndroid.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,6 +2762,7 @@ public class com/facebook/react/modules/intent/IntentModule : com/facebook/fbrea
27622762
public fun canOpenURL (Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V
27632763
public fun getInitialURL (Lcom/facebook/react/bridge/Promise;)V
27642764
public fun invalidate ()V
2765+
public fun openNotificationSettings (Lcom/facebook/react/bridge/Promise;)V
27652766
public fun openSettings (Lcom/facebook/react/bridge/Promise;)V
27662767
public fun openURL (Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V
27672768
public fun sendIntent (Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;Lcom/facebook/react/bridge/Promise;)V

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.app.Activity
1111
import android.content.Intent
1212
import android.net.Uri
1313
import android.nfc.NfcAdapter
14+
import android.os.Build
1415
import android.provider.Settings
1516
import com.facebook.fbreact.specs.NativeIntentAndroidSpec
1617
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
@@ -168,18 +169,7 @@ public open class IntentModule(reactContext: ReactApplicationContext) :
168169
*/
169170
override fun openSettings(promise: Promise) {
170171
try {
171-
val intent = Intent()
172-
val currentActivity: Activity = checkNotNull(reactApplicationContext.getCurrentActivity())
173-
val selfPackageName = reactApplicationContext.packageName
174-
175-
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
176-
intent.addCategory(Intent.CATEGORY_DEFAULT)
177-
intent.setData(Uri.parse("package:$selfPackageName"))
178-
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
179-
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
180-
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
181-
currentActivity.startActivity(intent)
182-
172+
startSettingsActivity(appDetailsSettingsIntent())
183173
promise.resolve(true)
184174
} catch (e: Exception) {
185175
promise.reject(
@@ -188,6 +178,45 @@ public open class IntentModule(reactContext: ReactApplicationContext) :
188178
}
189179
}
190180

181+
/**
182+
* Starts an external activity to open the app's notification settings into Android Settings.
183+
* On Android versions before 8.0 (API 26) this falls back to the app's details settings screen.
184+
*
185+
* @param promise a promise which is resolved when the Settings is opened
186+
*/
187+
override fun openNotificationSettings(promise: Promise) {
188+
try {
189+
val intent =
190+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
191+
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
192+
.putExtra(Settings.EXTRA_APP_PACKAGE, reactApplicationContext.packageName)
193+
} else {
194+
appDetailsSettingsIntent()
195+
}
196+
startSettingsActivity(intent)
197+
promise.resolve(null)
198+
} catch (e: Exception) {
199+
promise.reject(
200+
JSApplicationIllegalArgumentException(
201+
"Could not open the notification Settings: ${e.message}",
202+
),
203+
)
204+
}
205+
}
206+
207+
private fun appDetailsSettingsIntent(): Intent =
208+
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
209+
.addCategory(Intent.CATEGORY_DEFAULT)
210+
.setData(Uri.parse("package:${reactApplicationContext.packageName}"))
211+
212+
private fun startSettingsActivity(intent: Intent) {
213+
val currentActivity: Activity = checkNotNull(reactApplicationContext.getCurrentActivity())
214+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
215+
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
216+
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
217+
currentActivity.startActivity(intent)
218+
}
219+
191220
/**
192221
* Allows to send intents on Android
193222
*

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
package com.facebook.react.modules.intent
99

10+
import android.app.Activity
11+
import android.content.Intent
12+
import android.provider.Settings
1013
import com.facebook.react.bridge.LifecycleEventListener
1114
import com.facebook.react.bridge.Promise
1215
import com.facebook.react.bridge.ReactApplicationContext
@@ -19,10 +22,12 @@ import org.junit.runner.RunWith
1922
import org.mockito.kotlin.any
2023
import org.mockito.kotlin.argumentCaptor
2124
import org.mockito.kotlin.mock
25+
import org.mockito.kotlin.never
2226
import org.mockito.kotlin.times
2327
import org.mockito.kotlin.verify
2428
import org.mockito.kotlin.whenever
2529
import org.robolectric.RobolectricTestRunner
30+
import org.robolectric.annotation.Config
2631

2732
@RunWith(RobolectricTestRunner::class)
2833
class IntentModuleTest {
@@ -65,6 +70,80 @@ class IntentModuleTest {
6570
assertThat(promise.rejected).isEqualTo(0)
6671
}
6772

73+
@Test
74+
fun openNotificationSettings_startsAppNotificationSettingsIntent() {
75+
val activity = mock<Activity>()
76+
whenever(context.currentActivity).thenReturn(activity)
77+
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)
78+
79+
val promise = SimplePromise()
80+
intentModule.openNotificationSettings(promise)
81+
82+
val intentCaptor = argumentCaptor<Intent>()
83+
verify(activity).startActivity(intentCaptor.capture())
84+
val intent = intentCaptor.firstValue
85+
assertThat(intent.action).isEqualTo(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
86+
assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(TEST_PACKAGE_NAME)
87+
assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0)
88+
assertThat(promise.resolved).isEqualTo(1)
89+
assertThat(promise.rejected).isEqualTo(0)
90+
}
91+
92+
@Test
93+
@Config(sdk = [25])
94+
fun openNotificationSettings_beforeApi26_fallsBackToAppDetailsSettings() {
95+
val activity = mock<Activity>()
96+
whenever(context.currentActivity).thenReturn(activity)
97+
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)
98+
99+
val promise = SimplePromise()
100+
intentModule.openNotificationSettings(promise)
101+
102+
val intentCaptor = argumentCaptor<Intent>()
103+
verify(activity).startActivity(intentCaptor.capture())
104+
val intent = intentCaptor.firstValue
105+
assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
106+
assertThat(intent.data.toString()).isEqualTo("package:$TEST_PACKAGE_NAME")
107+
assertThat(promise.resolved).isEqualTo(1)
108+
assertThat(promise.rejected).isEqualTo(0)
109+
}
110+
111+
@Test
112+
fun openNotificationSettings_withoutActivity_rejects() {
113+
whenever(context.currentActivity).thenReturn(null)
114+
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)
115+
116+
val promise = SimplePromise()
117+
intentModule.openNotificationSettings(promise)
118+
119+
verify(context, never()).startActivity(any())
120+
assertThat(promise.resolved).isEqualTo(0)
121+
assertThat(promise.rejected).isEqualTo(1)
122+
}
123+
124+
@Test
125+
fun openSettings_startsAppDetailsSettingsIntent() {
126+
val activity = mock<Activity>()
127+
whenever(context.currentActivity).thenReturn(activity)
128+
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)
129+
130+
val promise = SimplePromise()
131+
intentModule.openSettings(promise)
132+
133+
val intentCaptor = argumentCaptor<Intent>()
134+
verify(activity).startActivity(intentCaptor.capture())
135+
val intent = intentCaptor.firstValue
136+
assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
137+
assertThat(intent.data.toString()).isEqualTo("package:$TEST_PACKAGE_NAME")
138+
assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0)
139+
assertThat(promise.resolved).isEqualTo(1)
140+
assertThat(promise.rejected).isEqualTo(0)
141+
}
142+
143+
private companion object {
144+
const val TEST_PACKAGE_NAME = "com.facebook.react.uiapp"
145+
}
146+
68147
internal class SimplePromise : Promise {
69148
companion object {
70149
private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED"

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<739253e7b90afea5959c264e1ac3d91a>>
7+
* @generated SignedSource<<99f7f9adf5a82815905413022cc48fa7>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -2872,6 +2872,7 @@ declare class LinkingImpl extends NativeEventEmitter<LinkingEventDefinitions> {
28722872
canOpenURL(url: string): Promise<boolean>
28732873
constructor()
28742874
getInitialURL(): Promise<null | string | undefined>
2875+
openNotificationSettings(): Promise<void>
28752876
openSettings(): Promise<void>
28762877
openURL(url: string): Promise<void>
28772878
sendIntent(
@@ -5882,7 +5883,7 @@ export {
58825883
LayoutChangeEvent, // 98960b70
58835884
LayoutConformanceProps, // 055f03b8
58845885
LayoutRectangle, // 6601b294
5885-
Linking, // 9a6a174d
5886+
Linking, // e5dcc78b
58865887
ListRenderItem, // b5353fd8
58875888
ListRenderItemInfo, // e8595b03
58885889
ListViewToken, // 833d3481

‎packages/react-native/src/private/specs_DEPRECATED/modules/NativeIntentAndroid.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface Spec extends TurboModule {
1717
readonly canOpenURL: (url: string) => Promise<boolean>;
1818
readonly openURL: (url: string) => Promise<void>;
1919
readonly openSettings: () => Promise<void>;
20+
readonly openNotificationSettings: () => Promise<void>;
2021
readonly sendIntent: (
2122
action: string,
2223
extras: ?Array<{

‎packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface Spec extends TurboModule {
1818
readonly canOpenURL: (url: string) => Promise<boolean>;
1919
readonly openURL: (url: string) => Promise<void>;
2020
readonly openSettings: () => Promise<void>;
21+
readonly openNotificationSettings: () => Promise<void>;
2122

2223
// Events
2324
readonly addListener: (eventName: string) => void;

0 commit comments

Comments
 (0)