Skip to content

Install on a connected physical Android device with --device - #141

Merged
janicduplessis merged 3 commits into
mainfrom
feat/android-physical-device
Aug 31, 2026
Merged

Install on a connected physical Android device with --device#141
janicduplessis merged 3 commits into
mainfrom
feat/android-physical-device

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #140

Description

stim android could only install on the owned emulator, so anything needing real hardware meant leaving Stim and driving Gradle and adb by hand.

Physical-device support was removed deliberately, in 32b96b2, which deleted the old --serial flag and wrote the ban into invariants 2 and 3 of AGENTS.md. That was right about ownership -- Stim must not create, boot, shut down, or delete somebody's phone -- but it conflated owning a device with installing on one. This PR keeps the ownership half and drops the rest, so those two invariants are rewritten rather than ignored.

Solution

stim android --device [serial] builds as usual, then installs and launches on a connected physical device. With no serial it uses the one connected device, and refuses with the candidate list when adb reports several. It cannot be combined with --remote.

debug_http_host and the dev-client deep link now resolve to localhost on a physical device instead of 10.0.2.2. This is a fix, not a preference: the emulator host loopback is not routable from a phone, so without it the app finds no bundler and loads no JS at all. The adb reverse that already ran serves localhost. Emulators keep 10.0.2.2.

Stim never records the phone as a device, so stop, gc, and teardown.ts -- all of which act on the project's device record -- have nothing to act on. The physical branch also skips the device-capacity check, AVD creation, and the boot wait, but keeps the build-slot cap, since the build runs locally either way.

A device that reports itself as an emulator is refused. Genymotion and
adb connect emulators appear to adb as host:port rather than emulator-N, so
serial shape alone would have let --device install on a user-created emulator.
ro.kernel.qemu and ro.hardware settle it.

--variant is unchanged; a flavored project still needs it, physical or not.

One device, many workspaces

Every workspace gets its own emulator, but they all share the one phone, so it is
worth being precise about what does and does not collide.

Two adb reverse pairs are registered: <port> -> <port>, which is what an app
with debug_http_host written actually uses, and 8081 -> <port>, the fallback
for an app whose prefs write failed. Only the first is per-workspace. Two
workspaces on 8082 and 8083 therefore do not interfere: each app follows its own
debug_http_host to its own Metro. Registering a device port replaces any
previous mapping for it, so the shared 8081 entry does belong to whichever
workspace ran last -- but that only decides where an app lands when its prefs
write failed, which is already a warned, degraded state.

Known limitation: two workspaces building the same applicationId overwrite
each other's install, because that is one package name and one device. The last
--device run wins. Emulator isolation does not extend to hardware, and this PR
does not pretend otherwise.

Risk

Contained. Nothing changes for an emulator run: the physical work is behind --device, and the host selection defaults to the existing 10.0.2.2. The one shared edit is the new physical parameter threaded through writeDebugHttpHost, androidDevClientUrl, and launchAndroidApp, all defaulting to false.

The exposure worth naming is that Stim now issues adb install, am start, and run-as against hardware it does not own. Each is scoped to a serial adb already reports as device, and the destructive-uninstall retry on a signature conflict is still gated on release variants only, as before. No new adb verb reaches a physical device that did not already reach an emulator.

Test plan

Verified end to end against a physical Samsung SM-G996W (RFCR7081Q9L), building tlon-apps develop:

device      SM-G996W (RFCR7081Q9L) connected, not owned by Stim
install     app-preview-debug.apk (37.9s)
launch      io.tlon.groups.preview (expo-dev-client deep link)
wired       debug_http_host localhost:8082 + adb reverse tcp:8081 -> tcp:8082
verify      ready: bundle loaded, stable for 3s, process alive (3s total)

verify ready: bundle loaded is the load-bearing line -- it is Metro actually serving the phone over the reverse, which is what the 10.0.2.2 to localhost change buys.

After the run, the project's entry in ~/.stim/config.json has no platforms record at all and stim status prints no android line, so stop and gc have nothing to act on.

Also exercised the real adb -s <serial> shell getprop ro.product.model call behind physicalDeviceModel, per invariant 9, and confirmed real adb devices output parses to the fixtures the unit tests use.

`stim android --device [serial]` builds as usual, then installs and
launches on a physical device connected to this machine. With no serial
it uses the one connected device and refuses with the candidate list
when adb reports several. It cannot be combined with --remote.

The physical path skips everything that manages an owned device: the
device-capacity check, AVD creation, the boot wait, and the device
record. Stim never creates, boots, shuts down, or deletes hardware, and
writes no serial to the project registry, so `stop`, `gc`, and
`teardown.ts` cannot reach it. The build, the fingerprint, the build
cache, and the Metro port gate are unchanged.

`debug_http_host` and the dev-client deep link now resolve to
localhost on a physical device, which the adb reverse serves. The
emulator keeps 10.0.2.2.
if (capacity) return fail(capacity.code, capacity.message, capacity.remedy);

const emuLog = emulatorLogFile(root);
const limits = getLimits();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLimits() is hoisted out of the emulator branch on purpose. The device-capacity check below it is emulator-only -- maxDevices counts devices Stim owns, and a physical device is not one -- but limits.maxBuilds gates the build slot further down, and the build runs locally whether the target is a phone or an emulator. Leaving the call inside the else branch left limits out of scope at the build-slot site.

- Read device properties through `runFile` with an argv, not a shell
  string built from a serial, per the single-exec-wrapper rule. Adds a
  timeout so a wedged USB link cannot hang the command.
- Refuse a physical device that reports itself as an emulator
  (`ro.kernel.qemu`, `ro.hardware`). Genymotion and `adb connect`
  emulators appear as `host:port`, so serial shape alone let `--device`
  install on a user-created emulator.
- Refuse an empty `--device` value. `--device=` yielded `''`, which is
  falsy, so the run silently used the owned emulator instead.
- Let an explicit `--device` win over the `android.remote` setting, the
  way `--variant` wins over `android.variant`. Only an explicit
  `--remote` now conflicts, and both argument refusals report
  STIM_BAD_ARG rather than a device-missing code.
- Pass `physical` to the unverified-launch recovery URL, which printed
  the emulator loopback as the way to reconnect a phone.
- Keep the whole adb status in a refusal. `no permissions; see [url]`
  was truncated to `no`.
- Name a signer or versionCode conflict in the install remedy instead of
  suggesting the cable, which a phone carrying a store build hits first.
- Update the website docs and the legacy-record note, which still said
  Stim does not operate on physical devices.
@janicduplessis
janicduplessis marked this pull request as ready for review August 31, 2026 20:39
@janicduplessis
janicduplessis merged commit 518965a into main Aug 31, 2026
7 of 8 checks passed
@janicduplessis
janicduplessis deleted the feat/android-physical-device branch August 31, 2026 20:39
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.

Support installing on a connected physical Android device

1 participant