diff --git a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts
index 9e70bdd5040a..924c3d3f131f 100644
--- a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts
+++ b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts
@@ -58,7 +58,8 @@ export type Permission =
| 'android.permission.READ_PHONE_NUMBERS'
| 'android.permission.UWB_RANGING'
| 'android.permission.POST_NOTIFICATIONS'
- | 'android.permission.NEARBY_WIFI_DEVICES';
+ | 'android.permission.NEARBY_WIFI_DEVICES'
+ | 'android.permission.ACCESS_LOCAL_NETWORK';
export type PermissionStatus = 'granted' | 'denied' | 'never_ask_again';
@@ -116,7 +117,8 @@ export interface PermissionsAndroidStatic {
| 'READ_PHONE_NUMBERS'
| 'UWB_RANGING'
| 'POST_NOTIFICATIONS'
- | 'NEARBY_WIFI_DEVICES']: Permission;
+ | 'NEARBY_WIFI_DEVICES'
+ | 'ACCESS_LOCAL_NETWORK']: Permission;
};
new (): PermissionsAndroidStatic;
/**
diff --git a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.js b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.js
index 10e6798612d2..799fc0070f40 100644
--- a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.js
+++ b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.js
@@ -75,6 +75,7 @@ type PermissionsType = Readonly<{
UWB_RANGING: 'android.permission.UWB_RANGING',
POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS',
NEARBY_WIFI_DEVICES: 'android.permission.NEARBY_WIFI_DEVICES',
+ ACCESS_LOCAL_NETWORK: 'android.permission.ACCESS_LOCAL_NETWORK',
}>;
export type PermissionStatus = 'granted' | 'denied' | 'never_ask_again';
@@ -125,6 +126,7 @@ const PERMISSIONS = Object.freeze({
UWB_RANGING: 'android.permission.UWB_RANGING',
POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS',
NEARBY_WIFI_DEVICES: 'android.permission.NEARBY_WIFI_DEVICES',
+ ACCESS_LOCAL_NETWORK: 'android.permission.ACCESS_LOCAL_NETWORK',
}) as PermissionsType;
/**
diff --git a/packages/react-native/ReactAndroid/src/debug/AndroidManifest.xml b/packages/react-native/ReactAndroid/src/debug/AndroidManifest.xml
index ac05d07ff627..e8b47a0f0815 100644
--- a/packages/react-native/ReactAndroid/src/debug/AndroidManifest.xml
+++ b/packages/react-native/ReactAndroid/src/debug/AndroidManifest.xml
@@ -7,6 +7,12 @@
-->
+
+
+
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java
index 16d44fe4c54a..44e013100133 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java
@@ -22,6 +22,7 @@
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.common.LifecycleState;
+import com.facebook.react.devsupport.LocalNetworkPermissionUtil;
import com.facebook.react.interfaces.fabric.ReactSurface;
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags;
import com.facebook.react.modules.core.PermissionListener;
@@ -168,7 +169,10 @@ protected ReactRootView createRootView() {
};
}
if (mainComponentName != null) {
- loadApp(mainComponentName);
+ LocalNetworkPermissionUtil.requestLocalNetworkAccessIfNeeded(
+ getPlainActivity(),
+ () -> loadApp(mainComponentName)
+ );
}
});
}
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/LocalNetworkPermissionUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/LocalNetworkPermissionUtil.kt
new file mode 100644
index 000000000000..a1f2a1670af6
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/LocalNetworkPermissionUtil.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+
+package com.facebook.react.devsupport
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import com.facebook.react.common.build.ReactBuildConfig
+import com.facebook.react.modules.core.PermissionAwareActivity
+import com.facebook.react.util.AndroidVersion
+
+/**
+ * Debug-only helper to request the runtime `ACCESS_LOCAL_NETWORK` permission needed to reach Metro
+ * on Android 17 (SDK 37) devices, which gate local-network addresses (the emulator's `10.0.2.2`
+ * alias, a device's Wi-Fi/LAN IP). Requested only in debuggable builds, and always (like iOS), since
+ * the dev-server host can change at runtime (e.g. switching from `adb reverse` to a LAN IP).
+ */
+internal object LocalNetworkPermissionUtil {
+ private const val PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
+ private const val PERMISSION_REQUEST_CODE = 1
+
+ /**
+ * Invokes [onResolved] once it is safe to connect to Metro: immediately when no permission is
+ * needed, or after the user answers the `ACCESS_LOCAL_NETWORK` prompt otherwise.
+ */
+ @JvmStatic
+ fun requestLocalNetworkAccessIfNeeded(activity: Activity, onResolved: Runnable) {
+ if (activity is PermissionAwareActivity && needsLocalNetworkPrompt(activity)) {
+ activity.requestPermissions(arrayOf(PERMISSION), PERMISSION_REQUEST_CODE) { _, _, _ ->
+ onResolved.run()
+ true
+ }
+ } else {
+ onResolved.run()
+ }
+ }
+
+ /** Whether the `ACCESS_LOCAL_NETWORK` prompt must be shown before reaching the dev server. */
+ private fun needsLocalNetworkPrompt(activity: Activity): Boolean {
+ if (!ReactBuildConfig.DEBUG) return false // dev-server only; never prompt in release builds
+ if (!AndroidVersion.isAtLeastSdk37()) return false // enforced by the device, not app targetSdk
+ return activity.checkSelfPermission(PERMISSION) != PackageManager.PERMISSION_GRANTED
+ }
+}
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt
index 3058a7a75ecd..c55a25709d53 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt
@@ -27,6 +27,13 @@ internal object AndroidVersion {
*/
internal const val VERSION_CODE_BAKLAVA: Int = 36
+ /**
+ * This is the version code for Android 17 (SDK Level 37). Internally at Meta this code is also
+ * compiled against SDK 34, so we need to retain this constant instead of using
+ * [Build.VERSION_CODES.CINNAMON_BUN] directly.
+ */
+ internal const val VERSION_CODE_CINNAMON_BUN: Int = 37
+
/**
* android.R.attr.windowOptOutEdgeToEdgeEnforcement added in API 35. Internally at Meta this code
* is compiled against an SDK that may not have this attribute defined.
@@ -51,4 +58,13 @@ internal object AndroidVersion {
fun isAtLeastTargetSdk36(context: Context): Boolean =
Build.VERSION.SDK_INT >= VERSION_CODE_BAKLAVA &&
context.applicationInfo.targetSdkVersion >= VERSION_CODE_BAKLAVA
+
+ /**
+ * This method is used to check if the current device is running Android 17 (SDK Level 37) or
+ * higher. Unlike the `isAtLeastTargetSdk*` helpers, this checks the device API level only and not
+ * the app's targetSdk, because Android 17 gates the local-network runtime permission for any app
+ * that declares it, regardless of targetSdk.
+ */
+ @JvmStatic
+ internal fun isAtLeastSdk37(): Boolean = Build.VERSION.SDK_INT >= VERSION_CODE_CINNAMON_BUN
}
diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts
index 05990bf66ec5..a41962ac2e77 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<<6fd7172a54686bbab8a03359976ef63d>>
+ * @generated SignedSource<>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
@@ -3548,6 +3548,7 @@ declare type PermissionsType = {
readonly ACCESS_BACKGROUND_LOCATION: "android.permission.ACCESS_BACKGROUND_LOCATION"
readonly ACCESS_COARSE_LOCATION: "android.permission.ACCESS_COARSE_LOCATION"
readonly ACCESS_FINE_LOCATION: "android.permission.ACCESS_FINE_LOCATION"
+ readonly ACCESS_LOCAL_NETWORK: "android.permission.ACCESS_LOCAL_NETWORK"
readonly ACCESS_MEDIA_LOCATION: "android.permission.ACCESS_MEDIA_LOCATION"
readonly ACTIVITY_RECOGNITION: "android.permission.ACTIVITY_RECOGNITION"
readonly ADD_VOICEMAIL: "com.android.voicemail.permission.ADD_VOICEMAIL"
@@ -6053,9 +6054,9 @@ export {
PanResponderCallbacks, // 6d63e7be
PanResponderGestureState, // 54baf558
PanResponderInstance, // 69cebbe8
- Permission, // 06473f4f
+ Permission, // 08f1c82f
PermissionStatus, // 4b7de97b
- PermissionsAndroid, // db2a401e
+ PermissionsAndroid, // 8a0bc8d8
PixelRatio, // 10d9e32d
Platform, // b73caa89
PlatformColor, // 8297ec62