Skip to content

fix(android): respect --device on run-android when multiple devices connected#2796

Open
janicduplessis wants to merge 2 commits intoreact-native-community:mainfrom
janicduplessis:@janic/run-android-respect-deviceid
Open

fix(android): respect --device on run-android when multiple devices connected#2796
janicduplessis wants to merge 2 commits intoreact-native-community:mainfrom
janicduplessis:@janic/run-android-respect-deviceid

Conversation

@janicduplessis
Copy link
Copy Markdown
Contributor

@janicduplessis janicduplessis commented May 5, 2026

Summary

react-native run-android --device <serial> (or the deprecated --deviceId) doesn't reliably install on the requested device when more than one is attached. runOnSpecificDevice builds the APK and runs two installs back to back:

if (!args.binaryPath) {
  build(gradleArgs, androidProject.sourceDir);    // gradle installDebug — picks a device on its own
}
installAndLaunchOnDevice(args, device, ...);      // tryInstallAppOnDevice → adb -s <device> install -r -d <apk>

The Gradle step uses getTaskNames(..., 'install') (so app:installDebug) but never sets ANDROID_SERIAL on the spawn nor passes -Pandroid.injected.serial=<device> (source), so AGP picks a device by its own logic. With a single device that's harmless (the redundant adb install is a no-op via -r). With two devices the Gradle step lands on the wrong one — the user-selected device still receives the APK via the subsequent adb install, but a stray install also ends up on the device the user did not ask for.

The interactive branch already swaps install* -> assemble* for exactly this reason; the non-interactive path doesn't. This PR mirrors that swap. Gradle stops at app:assembleDebug (or the corresponding flavored variant) and tryInstallAppOnDevice, which has always done adb -s <device> install -r -d <apk>, becomes the single source of truth for which device the APK lands on. Side benefit: every single-device run-android --device invocation now stops doing one redundant adb install per run. runOnAllDevices is intentionally left alone — it still uses 'install' because it fans the install across every connected device on purpose.

Test Plan

Verified on macOS with two emulators booted (emulator-5554 and emulator-5556).

Before: react-native run-android --deviceId emulator-5554 --port 8082 runs Gradle's installDebug on the other AVD:

> Task :app:installDebug
Installing APK 'app-debug.apk' on '<other AVD>(AVD) - 16' for :app:debug

After: same command, same two emulators connected:

> Task :app:assembleDebug UP-TO-DATE
BUILD SUCCESSFUL in 7s
info Installing the app on the device "emulator-5554"...
Performing Streamed Install
Success
info Starting the app on "emulator-5554"...

installDebug and the other AVD no longer appear in the log; the install lands only on the requested device.

Single-device runs (only emulator-5554 attached) produce the same assembleDebug -> tryInstallAppOnDevice -> adb -s emulator-5554 install sequence, which is what already happened in --interactive mode.

Why this is safe

installDebug is assembleDebug plus a postlude that calls adb install — so the APK is byte-identical and lives in the same build/outputs/apk/<variant>/ location that tryInstallAppOnDevice already walks. The dropped Gradle adb install is functionally identical to the adb -s <device> install -r -d <apk> that runs immediately after. The same assemble* + post-Gradle adb-install combination has been the active path for --interactive runs for years.

Edge cases route around the change:

  • --tasks <list> keeps the user's tasks via args.tasks ?? buildTask and ignores the prefix entirely.
  • --binary-path <apk> skips the Gradle build (if (!args.binaryPath) guard), unaffected.
  • Flavored variants (prodDebug etc.) get assembleProdDebug / their matching APK path; getInstallApkName handles both.
  • Single-device runs go from "Gradle install + redundant adb install" to "just adb install" on the same device — strictly less work, same outcome.

The realistic regression risk is custom Gradle plugins hooked into the installDebug task. Such users can opt back in by passing --tasks installDebug, and they were already in this same situation under --interactive.

Checklist

  • Documentation is up to date.
  • Follows commit message convention described in CONTRIBUTING.md.
  • For functional changes, my test plan has linked these CLI changes into a local react-native checkout (instructions).

…onnected

When `react-native run-android --device <serial>` is invoked with more
than one device or emulator attached, Gradle's `installDebug` task
silently installs on a device of its own choosing instead of the one
named by `--device`. The flag is honored only for the post-Gradle
`adb install` step in `tryInstallAppOnDevice`, which is too late: the
APK has already been pushed to the wrong device, and on a fresh
emulator the second adb install can fail outright due to a
debug-keystore mismatch left behind by the Gradle install.

Root cause: `runOnSpecificDevice` builds the Gradle task list with the
`'install'` prefix (yielding `app:installDebug`) but neither sets
`ANDROID_SERIAL` on the gradle spawn nor passes
`-Pandroid.injected.serial=<device>`, so AGP picks a device by its
own logic. The interactive path already side-steps this by swapping
the build task to `assemble*` (line ~230), but the non-interactive
path keeps using `install*`.

This change mirrors that swap for the non-interactive path: Gradle
builds `app:assembleDebug` (or the corresponding flavored variant) and
`tryInstallAppOnDevice` then runs `adb -s <device> install -r -d <apk>`
as it always has, so the install lands on the device the user asked
for. No environment variables, no AGP-injected properties, and no
behavior change when only one device is connected.

`runOnAllDevices` is unchanged: it still uses `install*` because it
intentionally fans the install across every connected device.
@janicduplessis janicduplessis marked this pull request as ready for review May 5, 2026 22:22
@janicduplessis janicduplessis requested a review from cortinico as a code owner May 5, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant