Skip to content

Commit d816ba0

Browse files
hrastnikfacebook-github-bot
authored andcommitted
Inject dev machine IP on Android and improve error message when connection fails (#49166)
Summary: I've implemented a feature that automatically bundles the Metro Bundler's IP address into Android builds. This change aligns the Android development experience with iOS, allowing the app to maintain a connection to the Metro Bundler even when disconnected from USB. Currently, in iOS builds, the IP address of the computer running the Metro Bundler is automatically bundled into the app, ensuring seamless connectivity even when the device is disconnected from USB. In contrast, Android developers must manually input the IP address if the USB connection is lost, which can be tedious and error-prone. More info in discussion thread: react-native-community/discussions-and-proposals#870 I anticipate that a change where making IP the default method of connection will result in a lot of people running into issues where they can't connect to Metro server (for example, if they're on a different network, or they disable wifi). So I also changed the default error message you get in case the app can't connect to the bundler and updated the "Change Bundle Location" dev menu. The previous error message ``` Unable to load script. Make sure you're either running Metro (run 'npx react-native start') or that your bundle 'RNTesterApp.android.bundle' is packaged correctly for release. ``` was changed to: ``` Unable to load script. Make sure you're running Metro (npx react-native start) or that your bundle 'RNTesterApp.android.bundle' is packaged correctly for release. The device must be on the same WiFi as your laptop to connect to Metro. To use USB instead, shake the device to open the dev menu and set the bundler location to 'localhost: 8081' and run: adb reverse tcp:8081 tcp:8081 ``` ![image](https://github.com/user-attachments/assets/f4002c7a-ff8a-4518-acf7-85af4257e05b) And the new dev menu UI looks like this: ![image](https://github.com/user-attachments/assets/ecaf6922-f074-4db9-b723-c4b18ececd91) The two buttons with "10.0.2.2:8081" and "localhost:8081" are suggestions which when tapped fill the input with the text from the button. The first button suggests the IP of the development machine, and the second one is hardcoded to localhost:8081. ## Changelog: [ANDROID] [CHANGED] - Automatically use Metro bundler IP address when installing apps on Android Pull Request resolved: #49166 Test Plan: I've tested the implementation on a physical device and on emulator and it's working solid. However, I would invite further testing in order to catch possible edge cases. I've recorded common scenarios Scenario 1: Device doesn't have the app installed. We connect the device via USB, install the app and open it. Device is on the same network as the dev machine. Bundler location is by default set to the IP of the dev machine. When starting app, the app is able to connect to the dev machine and download the bundle. Scenario 2: Device doesn't have the app installed. Wi-Fi is turned off on the device but device is connected via USB We install the app and open it. Bundler location is by default set to the IP of the dev machine. When starting app, the app is not able to connect to the dev machine and shows the error message. After opening the dev menu we see that the IP is set to the IP of the dev machine. We click the "localhost" option in the dev menu and click apply After that the app is able to connect to the dev machine and download the bundle (via USB) since the traffic is forwarded using adb reverse. Notes: When we set an IP in the dev menu, the app will persist it. If we connect the device via USB and reinstall the app the persisted data stays the same, so the previously set IP will be used. However, the IP of the dev machine will be displayed as an option in the dev menu. https://github.com/user-attachments/assets/cc2da5d4-de07-4980-a61c-68ca53db74c7 https://github.com/user-attachments/assets/407b8871-8b83-4a6b-a833-f87ddc0afc82 Reviewed By: huntie Differential Revision: D69664231 Pulled By: cortinico fbshipit-source-id: 5a339be50a17a59202416b99e72f4397d8ff4805
1 parent d60a9c1 commit d816ba0

File tree

10 files changed

+173
-24
lines changed

10 files changed

+173
-24
lines changed

packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import com.facebook.react.tasks.GenerateEntryPointTask
1717
import com.facebook.react.tasks.GeneratePackageListTask
1818
import com.facebook.react.utils.AgpConfiguratorUtils.configureBuildConfigFieldsForApp
1919
import com.facebook.react.utils.AgpConfiguratorUtils.configureBuildConfigFieldsForLibraries
20-
import com.facebook.react.utils.AgpConfiguratorUtils.configureDevPorts
20+
import com.facebook.react.utils.AgpConfiguratorUtils.configureDevServerLocation
2121
import com.facebook.react.utils.AgpConfiguratorUtils.configureNamespaceForLibraries
2222
import com.facebook.react.utils.BackwardCompatUtils.configureBackwardCompatibilityReactMap
2323
import com.facebook.react.utils.DependencyUtils.configureDependencies
@@ -71,7 +71,7 @@ class ReactPlugin : Plugin<Project> {
7171

7272
configureReactNativeNdk(project, extension)
7373
configureBuildConfigFieldsForApp(project, extension)
74-
configureDevPorts(project)
74+
configureDevServerLocation(project)
7575
configureBackwardCompatibilityReactMap(project)
7676
configureJavaToolChains(project)
7777

packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/AgpConfiguratorUtils.kt

+13-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import com.facebook.react.utils.ProjectUtils.areLegacyWarningsEnabled
1414
import com.facebook.react.utils.ProjectUtils.isHermesEnabled
1515
import com.facebook.react.utils.ProjectUtils.isNewArchEnabled
1616
import java.io.File
17+
import java.net.Inet4Address
18+
import java.net.NetworkInterface
1719
import javax.xml.parsers.DocumentBuilder
1820
import javax.xml.parsers.DocumentBuilderFactory
1921
import org.gradle.api.Action
@@ -53,13 +55,14 @@ internal object AgpConfiguratorUtils {
5355
}
5456
}
5557

56-
fun configureDevPorts(project: Project) {
58+
fun configureDevServerLocation(project: Project) {
5759
val devServerPort =
5860
project.properties["reactNativeDevServerPort"]?.toString() ?: DEFAULT_DEV_SERVER_PORT
5961

6062
val action =
6163
Action<AppliedPlugin> {
6264
project.extensions.getByType(AndroidComponentsExtension::class.java).finalizeDsl { ext ->
65+
ext.defaultConfig.resValue("string", "react_native_dev_server_ip", getHostIpAddress())
6366
ext.defaultConfig.resValue("integer", "react_native_dev_server_port", devServerPort)
6467
}
6568
}
@@ -107,3 +110,12 @@ fun getPackageNameFromManifest(manifest: File): String? {
107110
return null
108111
}
109112
}
113+
114+
internal fun getHostIpAddress(): String =
115+
NetworkInterface.getNetworkInterfaces()
116+
.asSequence()
117+
.filter { it.isUp && !it.isLoopback }
118+
.flatMap { it.inetAddresses.asSequence() }
119+
.filter { it is Inet4Address && !it.isLoopbackAddress }
120+
.map { it.hostAddress }
121+
.firstOrNull() ?: "localhost"

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

+1
Original file line numberDiff line numberDiff line change
@@ -3171,6 +3171,7 @@ public final class com/facebook/react/modules/systeminfo/AndroidInfoHelpers {
31713171
public static final fun getInspectorHostMetadata (Landroid/content/Context;)Ljava/util/Map;
31723172
public static final fun getServerHost (I)Ljava/lang/String;
31733173
public static final fun getServerHost (Landroid/content/Context;)Ljava/lang/String;
3174+
public static final fun getServerHost (Landroid/content/Context;I)Ljava/lang/String;
31743175
}
31753176

31763177
public final class com/facebook/react/modules/systeminfo/AndroidInfoModule : com/facebook/fbreact/specs/NativePlatformConstantsAndroidSpec {

packages/react-native/ReactAndroid/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,7 @@ android {
522522
buildConfigField("boolean", "UNSTABLE_ENABLE_MINIFY_LEGACY_ARCHITECTURE", "false")
523523

524524
resValue("integer", "react_native_dev_server_port", reactNativeDevServerPort())
525+
resValue("string", "react_native_dev_server_ip", "localhost")
525526

526527
testApplicationId = "com.facebook.react.tests.gradle"
527528
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
8+
package com.facebook.react.devsupport
9+
10+
import android.annotation.SuppressLint
11+
import android.app.AlertDialog
12+
import android.content.Context
13+
import android.text.InputType
14+
import android.widget.Button
15+
import android.widget.EditText
16+
import android.widget.LinearLayout
17+
import android.widget.TextView
18+
import com.facebook.react.R
19+
import com.facebook.react.modules.debug.interfaces.DeveloperSettings
20+
import com.facebook.react.modules.systeminfo.AndroidInfoHelpers.getAdbReverseTcpCommand
21+
import com.facebook.react.modules.systeminfo.AndroidInfoHelpers.getDevServerNetworkIpAndPort
22+
23+
internal object ChangeBundleLocationDialog {
24+
internal fun interface ChangeBundleLocationDialogListener {
25+
fun onClick(newHostAndPort: String)
26+
}
27+
28+
@SuppressLint("SetTextI18n")
29+
fun show(
30+
context: Context,
31+
devSettings: DeveloperSettings,
32+
onClickListener: ChangeBundleLocationDialogListener
33+
) {
34+
val settings = devSettings.packagerConnectionSettings
35+
val currentHost = settings.debugServerHost
36+
settings.debugServerHost = ""
37+
val defaultHost = settings.debugServerHost
38+
settings.debugServerHost = currentHost
39+
40+
val layout = LinearLayout(context)
41+
layout.orientation = LinearLayout.VERTICAL
42+
val paddingSmall = (4 * context.resources.displayMetrics.density).toInt()
43+
val paddingLarge = (16 * context.resources.displayMetrics.density).toInt()
44+
layout.setPadding(paddingLarge, paddingLarge, paddingLarge, paddingLarge)
45+
46+
val label = TextView(context)
47+
label.text = context.getString(R.string.catalyst_change_bundle_location_input_label)
48+
label.layoutParams =
49+
LinearLayout.LayoutParams(
50+
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
51+
52+
val input = EditText(context)
53+
// This makes it impossible to enter a newline in the input field
54+
input.inputType = InputType.TYPE_CLASS_TEXT
55+
input.hint = context.getString(R.string.catalyst_change_bundle_location_input_hint)
56+
input.setBackgroundResource(android.R.drawable.edit_text)
57+
input.setHintTextColor(-0x333334)
58+
input.setTextColor(-0x1000000)
59+
input.setText(currentHost)
60+
61+
val defaultHostSuggestion = Button(context)
62+
defaultHostSuggestion.text = defaultHost
63+
defaultHostSuggestion.textSize = 12f
64+
defaultHostSuggestion.isAllCaps = false
65+
defaultHostSuggestion.setOnClickListener { input.setText(defaultHost) }
66+
67+
val networkHost = getDevServerNetworkIpAndPort(context)
68+
val networkHostSuggestion = Button(context)
69+
networkHostSuggestion.text = networkHost
70+
networkHostSuggestion.textSize = 12f
71+
networkHostSuggestion.isAllCaps = false
72+
networkHostSuggestion.setOnClickListener { input.setText(networkHost) }
73+
74+
val suggestionRow = LinearLayout(context)
75+
suggestionRow.orientation = LinearLayout.HORIZONTAL
76+
suggestionRow.layoutParams =
77+
LinearLayout.LayoutParams(
78+
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
79+
suggestionRow.addView(defaultHostSuggestion)
80+
suggestionRow.addView(networkHostSuggestion)
81+
82+
val instructions = TextView(context)
83+
instructions.text =
84+
context.getString(
85+
R.string.catalyst_change_bundle_location_instructions, getAdbReverseTcpCommand(context))
86+
val instructionsParams =
87+
LinearLayout.LayoutParams(
88+
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
89+
instructionsParams.setMargins(0, paddingSmall, 0, paddingLarge)
90+
instructions.layoutParams = instructionsParams
91+
92+
val applyChangesButton = Button(context)
93+
applyChangesButton.text = context.getString(R.string.catalyst_change_bundle_location_apply)
94+
95+
val cancelButton = Button(context)
96+
cancelButton.text = context.getString(R.string.catalyst_change_bundle_location_cancel)
97+
98+
layout.addView(label)
99+
layout.addView(input)
100+
layout.addView(suggestionRow)
101+
layout.addView(instructions)
102+
layout.addView(applyChangesButton)
103+
layout.addView(cancelButton)
104+
105+
val dialog =
106+
AlertDialog.Builder(context)
107+
.setTitle(context.getString(R.string.catalyst_change_bundle_location))
108+
.setView(layout)
109+
.create()
110+
111+
applyChangesButton.setOnClickListener {
112+
onClickListener.onClick(input.text.toString())
113+
dialog.dismiss()
114+
}
115+
cancelButton.setOnClickListener { dialog.dismiss() }
116+
dialog.show()
117+
}
118+
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt

+5-15
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import android.view.Gravity
2525
import android.view.View
2626
import android.view.ViewGroup
2727
import android.widget.ArrayAdapter
28-
import android.widget.EditText
2928
import android.widget.LinearLayout
3029
import android.widget.ListAdapter
3130
import android.widget.TextView
@@ -86,6 +85,7 @@ public abstract class DevSupportManagerBase(
8685
public var devLoadingViewManager: DevLoadingViewManager?,
8786
private var pausedInDebuggerOverlayManager: PausedInDebuggerOverlayManager?
8887
) : DevSupportManager {
88+
8989
public interface CallbackWithBundleLoader {
9090
public fun onSuccess(bundleLoader: JSBundleLoader)
9191

@@ -341,20 +341,10 @@ public abstract class DevSupportManagerBase(
341341
return@DevOptionHandler
342342
}
343343

344-
val input = EditText(context)
345-
input.hint = "localhost:8081"
346-
347-
val bundleLocationDialog =
348-
AlertDialog.Builder(context)
349-
.setTitle(applicationContext.getString(R.string.catalyst_change_bundle_location))
350-
.setView(input)
351-
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
352-
val host = input.text.toString()
353-
devSettings.packagerConnectionSettings.debugServerHost = host
354-
handleReloadJS()
355-
}
356-
.create()
357-
bundleLocationDialog.show()
344+
ChangeBundleLocationDialog.show(context, devSettings) { host: String ->
345+
devSettings.packagerConnectionSettings.debugServerHost = host
346+
handleReloadJS()
347+
}
358348
}
359349

360350
options[applicationContext.getString(R.string.catalyst_inspector_toggle)] = DevOptionHandler {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoHelpers.kt

+14-3
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ public object AndroidInfoHelpers {
3030
private fun isRunningOnStockEmulator(): Boolean =
3131
Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.startsWith("google/sdk_gphone")
3232

33-
@JvmStatic public fun getServerHost(port: Int): String = getServerIpAddress(port)
33+
@JvmStatic public fun getServerHost(port: Int): String = getServerIpAddress(null, port)
3434

3535
@JvmStatic
36-
public fun getServerHost(context: Context): String = getServerIpAddress(getDevServerPort(context))
36+
public fun getServerHost(context: Context): String =
37+
getServerIpAddress(context, getDevServerPort(context))
38+
39+
@JvmStatic
40+
public fun getServerHost(context: Context, port: Int): String = getServerIpAddress(context, port)
3741

3842
@JvmStatic
3943
public fun getAdbReverseTcpCommand(port: Int): String = "adb reverse tcp:$port tcp:$port"
@@ -86,7 +90,7 @@ public object AndroidInfoHelpers {
8690
private fun getDevServerPort(context: Context): Int =
8791
context.resources.getInteger(R.integer.react_native_dev_server_port)
8892

89-
private fun getServerIpAddress(port: Int): String {
93+
private fun getServerIpAddress(context: Context?, port: Int): String {
9094
val ipAddress: String =
9195
when {
9296
getMetroHostPropValue().isNotEmpty() -> getMetroHostPropValue()
@@ -97,6 +101,13 @@ public object AndroidInfoHelpers {
97101
return String.format(Locale.US, "%s:%d", ipAddress, port)
98102
}
99103

104+
/**
105+
* Returns the devserver Network IP from the local network (LAN/Wifi) so that a physical device
106+
* could connect to the bundler through it.
107+
*/
108+
internal fun getDevServerNetworkIpAndPort(context: Context): String =
109+
"${context.resources.getString(R.string.react_native_dev_server_ip)}:${getDevServerPort(context)}"
110+
100111
@Synchronized
101112
private fun getMetroHostPropValue(): String {
102113
if (metroHostPropValue != null) {

packages/react-native/ReactAndroid/src/main/jni/react/jni/JSLoader.cpp

+10-3
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,16 @@ loadScriptFromAssets(AAssetManager* manager, const std::string& assetName) {
7878
}
7979

8080
throw std::runtime_error(
81-
"Unable to load script. Make sure you're "
82-
"either running Metro (run 'npx react-native start') or that your bundle '" +
83-
assetName + "' is packaged correctly for release.");
81+
"Unable to load script.\n\n"
82+
"Make sure you're running Metro or that your "
83+
"bundle '" +
84+
assetName +
85+
"' is packaged correctly for release.\n\n"
86+
"The device must either be USB connected (with bundler set to \"localhost:8081\") or be on "
87+
"the same Wi-Fi network as your computer (with bundler set to your computer IP) to connect "
88+
"to Metro.\n\n"
89+
"If you're using USB on a physical device, make sure you also run this command:\n"
90+
" adb reverse tcp:8081 tcp:8081");
8491
}
8592

8693
} // namespace facebook::react

packages/react-native/ReactAndroid/src/main/res/devsupport/values/strings.xml

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
<string name="catalyst_reload" project="catalyst" translatable="false">Reload</string>
44
<string name="catalyst_reload_error" project="catalyst" translatable="false">Failed to load bundle. Try restarting the bundler or reconnecting your device.</string>
55
<string name="catalyst_change_bundle_location" project="catalyst" translatable="false">Change Bundle Location</string>
6+
<string name="catalyst_change_bundle_location_input_label" project="catalyst" translatable="false">Provide a custom bundler address and port:</string>
7+
<string name="catalyst_change_bundle_location_input_hint" project="catalyst" translatable="false">127.0.0.1:8081</string>
8+
<string name="catalyst_change_bundle_location_instructions" project="catalyst" translatable="false">You can connect either via USB (localhost - default) or Wifi. If you connect via USB and running with a physical device, make sure you:\n 1. Connect your device via USB\n 2. Set the bundle location to `localhost:8081`\n 3. Run this command in your terminal:\n&#160;&#160;&#160;&#160;&#160;&#160;`%1$s`</string>
9+
<string name="catalyst_change_bundle_location_apply" project="catalyst" translatable="false">Apply Changes</string>
10+
<string name="catalyst_change_bundle_location_cancel" project="catalyst" translatable="false">Cancel</string>
611
<string name="catalyst_open_debugger_error" project="catalyst" translatable="false">Failed to open DevTools. Please check that the dev server is running and reload the app.</string>
712
<string name="catalyst_debug_open" project="catalyst" translatable="false">Open DevTools</string>
813
<string name="catalyst_debug_open_disabled" project="catalyst" translatable="false">Connect to the bundler to debug JavaScript</string>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<string name="react_native_dev_server_ip" project="catalyst" translatable="false">localhost</string>
4+
</resources>

0 commit comments

Comments
 (0)