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