Module: Everywhere::Emulator
- Defined in:
- lib/everywhere/emulator.rb
Overview
Thin wrappers around adb and emulator — just enough to boot a virtual
device, put the stamped APK on it and launch it. The Android counterpart of
Simulator, method for method, so every dev reads the same either way.
Every binary comes from AndroidSdk, never from PATH: Android Studio
provisions adb and the emulator and exports neither, so a machine that can
build Android apps all day has no adb on PATH (see AndroidSdk's preamble).
Constant Summary collapse
- BOOT_TIMEOUT =
Phase 0 measured a headless cold boot of Medium_Phone_API_35 at 16s to sys.boot_completed, and a windowed cold boot — which is what we default to, see #boot_argv — at roughly two minutes. Four minutes is generous enough to cover a slower machine or an AVD whose Quick Boot snapshot is being rebuilt, without pretending a genuinely wedged emulator will eventually come good.
240- POLL_INTERVAL =
1- LOOPBACK_ALIAS =
The emulator's alias for the host's own loopback interface: inside the guest, 127.0.0.1 is the guest, and 10.0.2.2 is the machine running it. Rung 2 of the dev-URL ladder (plan §2.3).
"10.0.2.2"- LOOPBACK_HOSTS =
Hosts that mean "this machine" in a dev URL and therefore mean the wrong machine once the URL is loaded inside the guest. 0.0.0.0 is here because a server bound to every interface is often printed as http://0.0.0.0:3000.
["127.0.0.1", "localhost", "::1", "0.0.0.0"].freeze
- DEFAULT_ACTIVITY =
The shell template's launcher activity, FULLY QUALIFIED on purpose.
am start -n <applicationId>/<activity>resolves a leading dot against the applicationId — but the template's manifest saysandroid:name=".MainActivity", which Gradle resolves against the namespace, and the namespace is frozen at com.rubyeverywhere.shell precisely so an app can rename its bundle id freely. The two are different packages for every real app, so any relative spelling here is wrong the moment applicationId != namespace. (The Hotwire demo's.main.MainActivityis a fact about the demo, whose applicationId happens to equal its namespace. It does not transfer.) "com.rubyeverywhere.shell.MainActivity"
Class Method Summary collapse
-
.adb_argv(serial, *args) ⇒ Object
Every adb invocation goes through here so the
-s <serial>targeting is never forgotten — an unqualified adb command with two devices attached fails with "more than one device", and with one emulator plus a phone it silently does the right thing on the wrong one. -
.boot_argv(avd, headless: false) ⇒ Object
WINDOWED BY DEFAULT, deliberately, even though headless boots roughly eight times faster (16s vs ~2m in the Phase 0 measurements).
- .boot_completed?(serial) ⇒ Boolean
-
.boot_default!(avd: nil, headless: false, timeout: BOOT_TIMEOUT) ⇒ Object
Reuse a running device, else boot an AVD and wait for it to be usable.
-
.booted_serial ⇒ Object
Serial of a device we can actually talk to, or nil.
-
.bounded_wait(argv, timeout: BOOT_TIMEOUT) ⇒ Object
Run a command with a deadline, and report whether it finished in time.
- .capture_install(serial, apk) ⇒ Object
-
.dev_url(url, serial:, port: nil, override: nil) ⇒ Object
Resolve the URL the Android shell should be pointed at, given the URL the dev server is on from the host's point of view.
-
.devices ⇒ Object
Parsed
adb devices -l. -
.emulator?(serial) ⇒ Boolean
adb names emulators after their console port (emulator-5554); physical devices carry a hardware serial.
- .host_of(url) ⇒ Object
-
.install(serial, apk, application_id: nil) ⇒ Object
-rreinstalls over an existing copy, keeping its data — the fast path, and the oneevery dev --androidtakes on every press ofa. -
.launch(serial, application_id, activity: DEFAULT_ACTIVITY) ⇒ Object
am startreports an unknown component asError: Activity class {…} does not exist.on stdout and still exits 0, so the output is what decides. -
.launcher_activity(serial, application_id) ⇒ Object
The launcher activity the installed APK actually declares.
- .loopback?(url) ⇒ Boolean
-
.pid_of(serial, application_id) ⇒ Object
The app's process id on the device, or nil when it isn't running.
- .port_of(url) ⇒ Object
-
.reverse(serial, port) ⇒ Object
Forward the device's
back to the host's . - .rewrite_loopback(url) ⇒ Object
-
.spawn_emulator(argv) ⇒ Object
The emulator has to outlive
every devthe way the iOS Simulator app does — quitting the CLI should not tear down a device you were using — so it leads its own process group and is detached. -
.started?(out, status) ⇒ Boolean
am startreports an unknown component on stdout and STILL exits 0, so a bare exit status is not an answer. -
.uninstall(serial, application_id) ⇒ Object
Not fatal: "not installed for user 0" is the normal answer on a clean device, and every caller only wants the package gone.
-
.wait_until_booted(serial, timeout: BOOT_TIMEOUT) ⇒ Object
adb wait-for-devicereturns as soon as the daemon can talk to the device, which Phase 0 clocked at 8s — a full 8s before the system is actually usable.
Class Method Details
.adb_argv(serial, *args) ⇒ Object
Every adb invocation goes through here so the -s <serial> targeting is
never forgotten — an unqualified adb command with two devices attached
fails with "more than one device", and with one emulator plus a phone it
silently does the right thing on the wrong one. A nil serial means "any
device", which is only ever wanted while waiting for the first one.
365 366 367 |
# File 'lib/everywhere/emulator.rb', line 365 def adb_argv(serial, *args) [AndroidSdk.adb!, *(serial ? ["-s", serial.to_s] : []), *args] end |
.boot_argv(avd, headless: false) ⇒ Object
WINDOWED BY DEFAULT, deliberately, even though headless boots roughly eight times faster (16s vs ~2m in the Phase 0 measurements).
The emulator window is the feedback surface of this dev loop — you press
a to look at your app. A headless boot installs and launches a shell you
cannot see, which is indistinguishable from a build that silently failed;
that is not hypothetical, it is what happened the first time this was run
by hand. The slow boot is a one-time cost per session, paid while other
work (the Gradle build) is happening anyway, and Quick Boot amortises it
away on every subsequent run. Invisibility is a cost paid every time,
and it is paid in "is this broken?".
headless: true stays available for CI and for anyone who really does
only want logs.
Notably absent: -no-snapshot. The spike passed it to force honest cold
boots for measurement; for a dev loop the opposite is wanted — Quick Boot
restores a saved state in seconds instead of running Android's whole
startup again. -no-audio stays regardless: the emulator grabbing the
host's audio device is a well-known source of hangs on macOS, and a web
shell has nothing to play.
143 144 145 146 147 |
# File 'lib/everywhere/emulator.rb', line 143 def boot_argv(avd, headless: false) argv = [AndroidSdk.emulator!, "-avd", avd, "-no-boot-anim", "-no-audio"] argv << "-no-window" if headless argv end |
.boot_completed?(serial) ⇒ Boolean
166 167 168 169 |
# File 'lib/everywhere/emulator.rb', line 166 def boot_completed?(serial) out, status = Shellout.capture(*adb_argv(serial, "shell", "getprop", "sys.boot_completed")) status&.success? && out.strip == "1" end |
.boot_default!(avd: nil, headless: false, timeout: BOOT_TIMEOUT) ⇒ Object
Reuse a running device, else boot an AVD and wait for it to be usable. Returns the serial.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/everywhere/emulator.rb', line 103 def boot_default!(avd: nil, headless: false, timeout: BOOT_TIMEOUT) if (serial = booted_serial) UI.step("reusing #{UI.bold(serial)}") return serial end name = avd || AndroidSdk.avds!.last UI.step("booting emulator #{UI.bold(name)}#{headless ? UI.dim(" (headless)") : ""}") spawn_emulator(boot_argv(name, headless: headless)) deadline = Time.now + timeout bounded_wait(adb_argv(nil, "wait-for-device"), timeout: timeout) serial = booted_serial or UI.die!("emulator #{name} never registered with adb — try booting it from Android " \ "Studio's Device Manager to see what it says") wait_until_booted(serial, timeout: [deadline - Time.now, POLL_INTERVAL].max) serial end |
.booted_serial ⇒ Object
Serial of a device we can actually talk to, or nil.
Emulators come first because they are what every dev --android boots and
what the dev-URL ladder can rewrite for; a plugged-in phone is still
accepted after them, since someone with a device attached and no emulator
running clearly meant that device.
59 60 61 62 |
# File 'lib/everywhere/emulator.rb', line 59 def booted_serial ready = devices.select { |d| d[:state] == "device" } (ready.find { |d| emulator?(d[:serial]) } || ready.first)&.fetch(:serial) end |
.bounded_wait(argv, timeout: BOOT_TIMEOUT) ⇒ Object
Run a command with a deadline, and report whether it finished in time.
This exists for adb wait-for-device, which blocks forever by design: an
AVD that fails to start would hang the dev loop with no output at all.
Waiting on the child ourselves is the only way to put a ceiling on it, so
the "never came up" path reaches a message a human can act on.
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
# File 'lib/everywhere/emulator.rb', line 386 def bounded_wait(argv, timeout: BOOT_TIMEOUT) pid = Process.spawn(Shellout.child_env, *argv, out: File::NULL, err: File::NULL) deadline = Time.now + timeout loop do return true if Process.waitpid(pid, Process::WNOHANG) break if Time.now > deadline sleep POLL_INTERVAL end begin Process.kill("TERM", pid) Process.waitpid(pid) rescue StandardError nil end false end |
.capture_install(serial, apk) ⇒ Object
200 201 202 |
# File 'lib/everywhere/emulator.rb', line 200 def capture_install(serial, apk) Shellout.capture(*adb_argv(serial, "install", "-r", apk)) end |
.dev_url(url, serial:, port: nil, override: nil) ⇒ Object
Resolve the URL the Android shell should be pointed at, given the URL the
dev server is on from the host's point of view. Returns a URL string;
every dev --android stamps whatever comes back into the debug build.
Three rungs, so a failure at any one degrades instead of dead-ending:
0. `override` (--dev-url / --host) short-circuits everything. Someone who
typed an address knows something we don't — typically that the app is
being opened on a physical phone over the LAN.
1. `adb reverse tcp:<port> tcp:<port>`. Preferred, because it leaves the
URL *literally correct*: 127.0.0.1:3000 on the device is 127.0.0.1:3000
on the host, so cookies, redirects, OAuth callbacks and anything the
app prints all keep matching. Works on emulators and physical devices
alike.
2. The 10.0.2.2 rewrite, if reverse failed and the target is an emulator.
Automatic and announced — the user is told the URL changed and why,
because a silently different origin is a genuinely confusing thing to
debug an hour later.
If none apply (a physical device, reverse failed, no override) the URL is returned unchanged with a warning naming the fix, rather than aborting a dev loop that is otherwise fine.
Emulator.dev_url("http://127.0.0.1:3000/", serial: "emulator-5554")
#=> "http://127.0.0.1:3000/" (rung 1) or "http://10.0.2.2:3000/" (rung 2)
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/everywhere/emulator.rb', line 308 def dev_url(url, serial:, port: nil, override: nil) unless override.to_s.empty? UI.step("dev URL #{UI.cyan(override)} #{UI.dim("(explicit override)")}") return override end # A URL that doesn't point at this machine needs no help: a LAN address or # a deployed https:// origin is already reachable from the device. return url unless loopback?(url) port ||= port_of(url) return url if port && reverse(serial, port) rewritten = rewrite_loopback(url) if emulator?(serial) && rewritten != url UI.warn "adb reverse failed — pointing the shell at #{UI.cyan(rewritten)} instead " \ "#{UI.dim("(#{LOOPBACK_ALIAS} is the emulator's alias for this machine)")}" return rewritten end UI.warn "adb reverse failed and #{serial || "this target"} isn't an emulator — " \ "#{UI.cyan(url)} will resolve to the device itself; pass a LAN address " \ "(--dev-url http://<this machine's IP>:#{port}) with the dev server bound to 0.0.0.0" url end |
.devices ⇒ Object
Parsed adb devices -l. Lines look like:
List of devices attached
emulator-5554 device product:sdk_gphone64_arm64 model:… transport_id:1
emulator-5556 offline
3A2B1C0D4E unauthorized usb:1-1
device is the only state that can take a command. offline is a device
the daemon has seen but can't talk to — an emulator still coming up, or one
that wedged — and unauthorized is a phone whose owner hasn't tapped
"Allow USB debugging" yet. Both are kept in the list (so callers can say
why nothing is usable) but neither is ever picked by #booted_serial:
installing against them fails with a message that reads like our bug.
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/everywhere/emulator.rb', line 77 def devices exe = AndroidSdk.adb or return [] out, status = Shellout.capture(exe, "devices", "-l") return [] unless status&.success? out.lines.filter_map do |line| # Skip the header and adb's own "* daemon started successfully" notes. next if line.start_with?("List of devices", "*") serial, state, = line.split next unless serial && state&.match?(/\A[a-z]+\z/) { serial: serial, state: state } end end |
.emulator?(serial) ⇒ Boolean
adb names emulators after their console port (emulator-5554); physical devices carry a hardware serial. That prefix is the standard test and it's what decides whether the 10.0.2.2 rewrite is even meaningful.
97 |
# File 'lib/everywhere/emulator.rb', line 97 def emulator?(serial) = serial.to_s.start_with?("emulator-") |
.host_of(url) ⇒ Object
346 347 348 349 350 |
# File 'lib/everywhere/emulator.rb', line 346 def host_of(url) URI.parse(url.to_s).host rescue URI::InvalidURIError nil end |
.install(serial, apk, application_id: nil) ⇒ Object
-r reinstalls over an existing copy, keeping its data — the fast path,
and the one every dev --android takes on every press of a.
It has one failure that no amount of retrying fixes: an APK signed by a different key than the copy already on the device is rejected with INSTALL_FAILED_UPDATE_INCOMPATIBLE, which is routine the first time a machine builds an app someone else's debug keystore installed. The only cure is removing the old package, so we do that once, out loud, rather than making the user decode the error.
Old adb builds printed Failure [...] and still exited 0, so the output
is checked as well as the status.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/everywhere/emulator.rb', line 185 def install(serial, apk, application_id: nil) out, status = capture_install(serial, apk) return if status&.success? && !out.include?("Failure [") if application_id && out.include?("INSTALL_FAILED_UPDATE_INCOMPATIBLE") UI.warn "#{application_id} is installed with a different signing key — reinstalling " \ "from scratch #{UI.dim("(its app data is dropped)")}" uninstall(serial, application_id) out, status = capture_install(serial, apk) return if status&.success? && !out.include?("Failure [") end UI.die!("adb install failed: #{out.strip}") end |
.launch(serial, application_id, activity: DEFAULT_ACTIVITY) ⇒ Object
am start reports an unknown component as Error: Activity class {…} does not exist. on stdout and still exits 0, so the output is what decides.
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/everywhere/emulator.rb', line 213 def launch(serial, application_id, activity: DEFAULT_ACTIVITY) component = "#{application_id}/#{activity}" out, status = Shellout.capture(*adb_argv(serial, "shell", "am", "start", "-n", component)) return if started?(out, status) # The APK on the device is the authority on its own launcher activity, so # rather than dying on a stale constant, ask it. This costs one round trip # only on the failure path, and it means moving MainActivity in the # template can never silently break the dev loop again — which is exactly # how this broke the first time. resolved = launcher_activity(serial, application_id) if resolved && resolved != activity retry_component = "#{application_id}/#{resolved}" retry_out, retry_status = Shellout.capture( *adb_argv(serial, "shell", "am", "start", "-n", retry_component) ) if started?(retry_out, retry_status) UI.warn "launched #{resolved} — #{activity} is stale, update Emulator::DEFAULT_ACTIVITY" return end end UI.die!("couldn't launch #{component}: #{out.strip}") end |
.launcher_activity(serial, application_id) ⇒ Object
The launcher activity the installed APK actually declares. cmd package resolve-activity --brief prints <package>/<activity> on its last line;
anything else (old adb, no match) answers nil so the caller can fail with
the original, more useful error.
248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/everywhere/emulator.rb', line 248 def launcher_activity(serial, application_id) out, status = Shellout.capture(*adb_argv(serial, "shell", "cmd", "package", "resolve-activity", "--brief", application_id)) return unless status&.success? line = out.to_s.lines.map(&:strip).reject(&:empty?).last return unless line&.include?("/") package, activity = line.split("/", 2) activity if package == application_id && !activity.to_s.empty? end |
.loopback?(url) ⇒ Boolean
334 |
# File 'lib/everywhere/emulator.rb', line 334 def loopback?(url) = LOOPBACK_HOSTS.include?(host_of(url)) |
.pid_of(serial, application_id) ⇒ Object
The app's process id on the device, or nil when it isn't running. pidof
exits non-zero and prints nothing in that case, which is exactly the
distinction every logs --android needs to make.
263 264 265 266 267 |
# File 'lib/everywhere/emulator.rb', line 263 def pid_of(serial, application_id) out, status = Shellout.capture(*adb_argv(serial, "shell", "pidof", application_id)) pid = out.to_s.split.first pid if status&.success? && pid&.match?(/\A\d+\z/) end |
.port_of(url) ⇒ Object
352 353 354 355 356 |
# File 'lib/everywhere/emulator.rb', line 352 def port_of(url) URI.parse(url.to_s).port rescue URI::InvalidURIError nil end |
.reverse(serial, port) ⇒ Object
Forward the device's
It succeeds with nothing listening on the host port (verified in Phase 0), so it can run before the dev server is up. On success adb prints the port number to stdout — a wrapper that read non-empty output as failure would get this exactly backwards.
276 277 278 279 |
# File 'lib/everywhere/emulator.rb', line 276 def reverse(serial, port) _out, status = Shellout.capture(*adb_argv(serial, "reverse", "tcp:#{port}", "tcp:#{port}")) !!status&.success? end |
.rewrite_loopback(url) ⇒ Object
336 337 338 339 340 341 342 343 344 |
# File 'lib/everywhere/emulator.rb', line 336 def rewrite_loopback(url) uri = URI.parse(url.to_s) return url unless LOOPBACK_HOSTS.include?(uri.host) uri.host = LOOPBACK_ALIAS uri.to_s rescue URI::InvalidURIError url end |
.spawn_emulator(argv) ⇒ Object
The emulator has to outlive every dev the way the iOS Simulator app
does — quitting the CLI should not tear down a device you were using — so
it leads its own process group and is detached. Its INFO/WARNING banners
go to /dev/null: they would otherwise land in the middle of the dev
server's log, and nothing here reads them.
374 375 376 377 378 |
# File 'lib/everywhere/emulator.rb', line 374 def spawn_emulator(argv) pid = Process.spawn(Shellout.child_env, *argv, pgroup: true, out: File::NULL, err: File::NULL) Process.detach(pid) pid end |
.started?(out, status) ⇒ Boolean
am start reports an unknown component on stdout and STILL exits 0, so a
bare exit status is not an answer.
240 241 242 |
# File 'lib/everywhere/emulator.rb', line 240 def started?(out, status) !!status&.success? && !out.to_s.match?(/^Error:/) end |
.uninstall(serial, application_id) ⇒ Object
Not fatal: "not installed for user 0" is the normal answer on a clean device, and every caller only wants the package gone.
206 207 208 209 |
# File 'lib/everywhere/emulator.rb', line 206 def uninstall(serial, application_id) _out, status = Shellout.capture(*adb_argv(serial, "uninstall", application_id)) !!status&.success? end |
.wait_until_booted(serial, timeout: BOOT_TIMEOUT) ⇒ Object
adb wait-for-device returns as soon as the daemon can talk to the
device, which Phase 0 clocked at 8s — a full 8s before the system is
actually usable. So it's the start of the wait, not the end: what says
"ready" is sys.boot_completed flipping to 1.
153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/everywhere/emulator.rb', line 153 def wait_until_booted(serial, timeout: BOOT_TIMEOUT) deadline = Time.now + timeout bounded_wait(adb_argv(serial, "wait-for-device"), timeout: timeout) until Time.now > deadline return serial if boot_completed?(serial) sleep POLL_INTERVAL end UI.die!("emulator #{serial} never finished booting " \ "(sys.boot_completed still unset after #{UI.elapsed(timeout)})") end |