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 says android: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.MainActivity is a fact about the demo, whose applicationId happens to equal its namespace. It does not transfer.)

"com.rubyeverywhere.shell.MainActivity"

Class Method Summary collapse

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

Returns:

  • (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_serialObject

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

.devicesObject

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.

Returns:

  • (Boolean)


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

Returns:

  • (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 back to the host's . Returns whether it took, because that answer is rung 1 of the dev-URL ladder below.

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.

Returns:

  • (Boolean)


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