Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/jest-preset/jest/mocks/Linking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
16 changes: 16 additions & 0 deletions packages/react-native/Libraries/Linking/Linking.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ class LinkingImpl extends NativeEventEmitter<LinkingEventDefinitions> {
}
}

/**
* 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<void> {
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
Expand Down
90 changes: 90 additions & 0 deletions packages/react-native/Libraries/Linking/__tests__/Linking-test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
31 changes: 28 additions & 3 deletions packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}];
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -65,6 +70,80 @@ class IntentModuleTest {
assertThat(promise.rejected).isEqualTo(0)
}

@Test
fun openNotificationSettings_startsAppNotificationSettingsIntent() {
val activity = mock<Activity>()
whenever(context.currentActivity).thenReturn(activity)
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)

val promise = SimplePromise()
intentModule.openNotificationSettings(promise)

val intentCaptor = argumentCaptor<Intent>()
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<Activity>()
whenever(context.currentActivity).thenReturn(activity)
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)

val promise = SimplePromise()
intentModule.openNotificationSettings(promise)

val intentCaptor = argumentCaptor<Intent>()
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<Activity>()
whenever(context.currentActivity).thenReturn(activity)
whenever(context.packageName).thenReturn(TEST_PACKAGE_NAME)

val promise = SimplePromise()
intentModule.openSettings(promise)

val intentCaptor = argumentCaptor<Intent>()
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"
Expand Down
5 changes: 3 additions & 2 deletions packages/react-native/ReactNativeApi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<<b1273b105161b7b40e5571f4235a4812>>
* @generated SignedSource<<1f73b02dee445f4054d04e94db78f78b>>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
Expand Down Expand Up @@ -2872,6 +2872,7 @@ declare class LinkingImpl extends NativeEventEmitter<LinkingEventDefinitions> {
canOpenURL(url: string): Promise<boolean>
constructor()
getInitialURL(): Promise<null | string | undefined>
openNotificationSettings(): Promise<void>
openSettings(): Promise<void>
openURL(url: string): Promise<void>
sendIntent(
Expand Down Expand Up @@ -5882,7 +5883,7 @@ export {
LayoutChangeEvent, // 98960b70
LayoutConformanceProps, // 055f03b8
LayoutRectangle, // 6601b294
Linking, // 9a6a174d
Linking, // e5dcc78b
ListRenderItem, // b5353fd8
ListRenderItemInfo, // e8595b03
ListViewToken, // 833d3481
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface Spec extends TurboModule {
readonly canOpenURL: (url: string) => Promise<boolean>;
readonly openURL: (url: string) => Promise<void>;
readonly openSettings: () => Promise<void>;
readonly openNotificationSettings: () => Promise<void>;
readonly sendIntent: (
action: string,
extras: ?Array<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface Spec extends TurboModule {
readonly canOpenURL: (url: string) => Promise<boolean>;
readonly openURL: (url: string) => Promise<void>;
readonly openSettings: () => Promise<void>;
readonly openNotificationSettings: () => Promise<void>;

// Events
readonly addListener: (eventName: string) => void;
Expand Down
Loading