Module: Everywhere::AndroidSdk

Defined in:
lib/everywhere/android_sdk.rb

Overview

Locates the Android toolchain — SDK, adb, emulator, sdkmanager, JDK — for the builder, the emulator driver, every logs and every doctor.

The one rule everything here follows: resolve BY LOCATION, never off PATH. Android Studio provisions the whole toolchain (SDK, platform-tools, the emulator, system images, a bundled JDK) and exports none of it — no shell profile is touched, so a machine that can build Android apps all day has no adb, no sdkmanager and often no java on PATH at all. A which adb here would report "missing" on a perfectly provisioned Mac. Fixed locations under the SDK root are what's actually stable, the same way xcrun shields the iOS builder from wherever Xcode put its internals.

Constant Summary collapse

MINIMUM_JDK =

Android Gradle Plugin 8.x refuses to run on anything older, and the failure surfaces as an unreadable Gradle/Kotlin stacktrace rather than a version complaint — so we check before Gradle ever starts.

17
COMPILE_SDK =

The API level the shell template compiles against. Named here (not in the builder) because every doctor has to print the sdkmanager line for it long before a build is attempted.

35
STUDIO_JBR =

Android Studio's bundled JetBrains Runtime, per platform. Studio never exports it, so these paths are the only way to find a JDK on a machine whose owner installed Studio and nothing else.

[
  "/Applications/Android Studio.app/Contents/jbr/Contents/Home",
  "~/Applications/Android Studio.app/Contents/jbr/Contents/Home",
  "/opt/android-studio/jbr",
  "/usr/local/android-studio/jbr",
  "~/android-studio/jbr",
  "C:/Program Files/Android/Android Studio/jbr"
].freeze
CMDLINE_TOOLS_URL =

How to get command-line tools without already having them. The Studio route is first because it also accepts the licences, but the manual zip matters: it's the only answer for a machine with no Studio, and Studio's own SDK Tools list is exactly where this component was already unchecked.

"https://developer.android.com/studio#command-line-tools-only"

Class Method Summary collapse

Class Method Details

.adbObject

--- tools ---------------------------------------------------------------



92
# File 'lib/everywhere/android_sdk.rb', line 92

def adb      = sdk_tool("platform-tools", "adb")

.adb!Object



95
96
97
# File 'lib/everywhere/android_sdk.rb', line 95

def adb!
  adb or UI.die!("no adb under #{sdk_root!}#{install_hint("platform-tools")}")
end

.avdmanagerObject



104
# File 'lib/everywhere/android_sdk.rb', line 104

def avdmanager = cmdline_tool("avdmanager")

.avdmanager!Object



110
111
112
# File 'lib/everywhere/android_sdk.rb', line 110

def avdmanager!
  avdmanager or UI.die!("no avdmanager under #{sdk_root!}#{cmdline_tools_hint}")
end

.avdsObject

Virtual devices, newest-created last. Asked of the emulator binary rather than globbed out of ~/.android/avd because the emulator also honours ANDROID_AVD_HOME and ANDROID_SDK_HOME — and an AVD it won't list is one we couldn't boot anyway. Studio's Device Manager writes to the same place, so a VM created in the GUI shows up here with no extra step.



299
300
301
302
303
304
305
306
307
308
# File 'lib/everywhere/android_sdk.rb', line 299

def avds
  exe = emulator or return []

  out, status = Shellout.capture(exe, "-list-avds")
  return [] unless status&.success?

  # The emulator prints INFO/WARNING banners on the same stream; AVD names
  # can only contain [A-Za-z0-9._-], so anything with a space is chatter.
  out.lines.map(&:strip).reject(&:empty?).grep(/\A[\w.-]+\z/)
end

.avds!Object



310
311
312
313
314
315
# File 'lib/everywhere/android_sdk.rb', line 310

def avds!
  found = avds
  return found if found.any?

  UI.die!("no Android virtual devices — #{create_avd_hint}")
end

.cmdline_tool(name) ⇒ Object

sdkmanager and avdmanager are the one pair that moved: current installs put them in cmdline-tools/latest/bin, an SDK pinned to a specific command-line-tools release uses cmdline-tools//bin, and anything provisioned before 2021 still has the legacy tools/bin copy. Try all three so every doctor can print a working install line on an older machine instead of a path that doesn't exist.



163
164
165
166
167
168
169
170
171
172
# File 'lib/everywhere/android_sdk.rb', line 163

def cmdline_tool(name)
  root = sdk_root or return nil

  # Reverse-sorted so 12.0 beats 9.0 in the common case; "latest" is tried
  # first anyway, so this ordering only decides between pinned releases.
  versioned = Dir.glob(File.join(root, "cmdline-tools", "*", "bin")).sort.reverse
  dirs = [File.join(root, "cmdline-tools", "latest", "bin"), *versioned,
          File.join(root, "tools", "bin")]
  dirs.uniq.filter_map { |dir| tool_in(dir, name) }.first
end

.cmdline_tools?Boolean

Whether this SDK can install anything at all. Worth asking on its own, because it's the one component whose absence makes every other remediation unquotable: without it there is no sdkmanager to run, and the Phase 0 machine had a complete SDK, adb, the emulator, build-tools and an AVD with no cmdline-tools anywhere — not in the SDK, not inside Android Studio. So a missing one is an advisory, not a broken machine.

Returns:

  • (Boolean)


120
# File 'lib/everywhere/android_sdk.rb', line 120

def cmdline_tools? = !sdkmanager.nil?

.cmdline_tools_dirObject

Where an unzipped command-line-tools release has to end up. Named because both hints point at it, and because "unzip it somewhere in the SDK" is the instruction people get wrong — the tools look for their own jars relative to this exact directory.



139
140
141
# File 'lib/everywhere/android_sdk.rb', line 139

def cmdline_tools_dir
  File.join(sdk_root || expected_sdk_root, "cmdline-tools", "latest")
end

.cmdline_tools_hintObject



128
129
130
131
132
133
# File 'lib/everywhere/android_sdk.rb', line 128

def cmdline_tools_hint
  "install \"Android SDK Command-line Tools (latest)\" in Android Studio → Settings → " \
    "Languages & Frameworks → Android SDK → SDK Tools, or download " \
    "commandlinetools-#{host_tag}-*_latest.zip from #{CMDLINE_TOOLS_URL} and unzip it into " \
    "#{cmdline_tools_dir} (so its bin/ lands there)"
end

.create_avd_hintObject

How to get a bootable AVD. The GUI route comes first because a machine with Studio installed almost always has a device defined already, and Device Manager also downloads the system image the CLI line assumes.



320
321
322
323
324
# File 'lib/everywhere/android_sdk.rb', line 320

def create_avd_hint
  "create one in Android Studio → Device Manager, or run: " \
  "#{(avdmanager || "avdmanager").shellescape} create avd -n Pixel_API_#{COMPILE_SDK} " \
  "-k \"system-images;android-#{COMPILE_SDK};google_apis;#{host_abi}\""
end

.default_sdk_rootsObject

Where the SDK lives on a default install, per platform. Studio writes exactly one of these and records it in its own settings — never in the environment — which is why the fallback matters more here than it would for a toolchain with an installer that edits your profile.



55
56
57
58
59
60
61
62
63
# File 'lib/everywhere/android_sdk.rb', line 55

def default_sdk_roots
  if macos?
    ["~/Library/Android/sdk"]
  elsif windows?
    [ENV["LOCALAPPDATA"] && File.join(ENV["LOCALAPPDATA"], "Android", "Sdk")].compact
  else
    ["~/Android/Sdk"]
  end
end

.emulatorObject



93
# File 'lib/everywhere/android_sdk.rb', line 93

def emulator = sdk_tool("emulator", "emulator")

.emulator!Object



99
100
101
# File 'lib/everywhere/android_sdk.rb', line 99

def emulator!
  emulator or UI.die!("no Android emulator under #{sdk_root!}#{install_hint("emulator")}")
end

.expected_sdk_rootObject

The path named in "we couldn't find it" messages, so the user is told where we looked rather than left to guess.



86
87
88
# File 'lib/everywhere/android_sdk.rb', line 86

def expected_sdk_root
  File.expand_path(default_sdk_roots.first || "~/Android/Sdk")
end

.host_abiObject

The system image ABI that runs at native speed on this host — an x86_64 image on Apple silicon boots, technically, at a speed nobody will wait for.



328
# File 'lib/everywhere/android_sdk.rb', line 328

def host_abi = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "arm64-v8a" : "x86_64"

.host_tagObject

The token Google's command-line-tools downloads are named with.



144
145
146
147
148
# File 'lib/everywhere/android_sdk.rb', line 144

def host_tag
  return "mac" if macos?

  windows? ? "win" : "linux"
end

.install_hint(package) ⇒ Object

A ready-to-paste instruction for installing a missing SDK package. When the SDK has no command-line tools at all there IS no command to paste, so say what to click in Studio instead — an honest instruction beats a copyable line that resolves to nothing. Phrased to read on its own, since callers append it after an em dash.



187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/everywhere/android_sdk.rb', line 187

def install_hint(package)
  if (manager = sdkmanager)
    "run: #{manager.shellescape} #{package.inspect}"
  else
    # The short form of cmdline_tools_hint: doctor prints the full version
    # once, above these lines, and repeating a URL and two absolute paths
    # on every missing package buries what each line is actually about.
    "install \"Android SDK Command-line Tools\" (Android Studio → SDK Tools, or unzip " \
    "commandlinetools-#{host_tag}-*_latest.zip into #{cmdline_tools_dir}), " \
    "then run: sdkmanager #{package.inspect}"
  end
end

.installed_build_toolsObject



265
# File 'lib/everywhere/android_sdk.rb', line 265

def installed_build_tools = sdk_package_dirs("build-tools")

.installed_platformsObject

Which platforms/build-tools are on disk, read straight off the filesystem rather than from sdkmanager --list: that's a multi-second JVM start that also reaches out for the remote catalogue we don't care about, and it needs the command-line tools, which are exactly the component most likely to be missing on a Studio-only machine.



264
# File 'lib/everywhere/android_sdk.rb', line 264

def installed_platforms   = sdk_package_dirs("platforms")

.java_homeObject

Gradle needs a JDK. JAVA_HOME wins because it's the one explicit choice on the machine; then macOS's own registry (which knows about JDKs installed by brew, Oracle or SDKMAN without any of them being on PATH); then Studio's bundled runtime, which is what a Studio-only machine actually has.



206
207
208
209
210
211
# File 'lib/everywhere/android_sdk.rb', line 206

def java_home
  explicit = ENV["JAVA_HOME"].to_s
  return File.expand_path(explicit) if !explicit.empty? && File.directory?(explicit)

  registry_java_home || studio_java_home
end

.java_home!(minimum: MINIMUM_JDK) ⇒ Object

Like #java_home but also enforces the minimum: a JDK 11 that resolves cleanly still fails the build later, deep inside Gradle, so reject it here where the message can name the cause.



216
217
218
219
220
221
222
223
224
225
# File 'lib/everywhere/android_sdk.rb', line 216

def java_home!(minimum: MINIMUM_JDK)
  home = java_home or
    UI.die!("no JDK — install Android Studio (it bundles one at #{STUDIO_JBR.first}) " \
            "or set JAVA_HOME to a JDK #{minimum} or newer")
  version = java_version
  return home if version.nil? || version >= minimum

  UI.die!("#{home} is JDK #{version}; the Android Gradle Plugin needs #{minimum} or newer — " \
          "set JAVA_HOME to a JDK #{minimum}+, or install Android Studio and use its bundled runtime")
end

.java_versionObject

The JDK's major version, or nil if it can't be determined. Read from the release file the JDK ships rather than by running java -version: it's a file read instead of a JVM start, and it works for a JDK that isn't on PATH — which, per the rule at the top, is most of them. A JDK 8 reports JAVA_VERSION="1.8.0_412", so this yields 1, which compares correctly against MINIMUM_JDK without special-casing the old scheme.



233
234
235
236
237
238
239
240
241
# File 'lib/everywhere/android_sdk.rb', line 233

def java_version
  home = java_home or return nil
  release = File.join(home, "release")
  return File.read(release)[/^JAVA_VERSION="?(\d+)/, 1]&.to_i if File.file?(release)

  java = tool_in(File.join(home, "bin"), "java") or return nil
  out, status = Shellout.capture(java, "-version")
  out[/version "?(\d+)/, 1]&.to_i if status&.success?
end

.macos?Boolean

--- platform ------------------------------------------------------------

Returns:

  • (Boolean)


46
# File 'lib/everywhere/android_sdk.rb', line 46

def macos?   = RUBY_PLATFORM.include?("darwin")

.platform!(api = COMPILE_SDK) ⇒ Object



283
284
285
286
287
288
289
290
# File 'lib/everywhere/android_sdk.rb', line 283

def platform!(api = COMPILE_SDK)
  return true if platform?(api)

  package = "platforms;android-#{api.to_s.delete_prefix("android-")}"
  UI.die!("the Android SDK has no #{package} (installed: " \
          "#{installed_platforms.empty? ? "none" : installed_platforms.join(", ")}) — " \
          "#{install_hint(package)}")
end

.platform?(api = COMPILE_SDK) ⇒ Boolean

platform?(35) and platform?("android-35") both work — callers spell it whichever way reads better where they are.

Returns:

  • (Boolean)


278
279
280
281
# File 'lib/everywhere/android_sdk.rb', line 278

def platform?(api = COMPILE_SDK)
  name = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"
  installed_platforms.include?(name)
end

.registry_java_homeObject

macOS keeps a registry of installed JDKs that java_home queries; -v 17 asks for "17 or newer", so a machine with only JDK 21 still answers.



245
246
247
248
249
250
251
# File 'lib/everywhere/android_sdk.rb', line 245

def registry_java_home
  return nil unless macos?

  out, status = Shellout.capture("/usr/libexec/java_home", "-v", MINIMUM_JDK.to_s)
  home = out.to_s.strip
  home if status&.success? && !home.empty? && File.directory?(home)
end

.sdk_package_dirs(kind) ⇒ Object



267
268
269
270
271
272
273
274
# File 'lib/everywhere/android_sdk.rb', line 267

def sdk_package_dirs(kind)
  root = sdk_root or return []

  Dir.glob(File.join(root, kind, "*"))
     .select { |dir| File.directory?(dir) }
     .map { |dir| File.basename(dir) }
     .sort
end

.sdk_rootObject

The SDK root, or nil. An explicit env var wins so CI images and secondary SDKs work without moving anything; ANDROID_HOME comes first because it's the spelling Studio and the docs use now (ANDROID_SDK_ROOT is deprecated but still all over CI). A var pointing at a directory that isn't there is skipped rather than fatal — a stale export shouldn't hide the SDK sitting in the default location.



71
72
73
74
75
76
# File 'lib/everywhere/android_sdk.rb', line 71

def sdk_root
  [ENV["ANDROID_HOME"], ENV["ANDROID_SDK_ROOT"], *default_sdk_roots]
    .reject { |dir| dir.to_s.empty? }
    .map { |dir| File.expand_path(dir) }
    .find { |dir| File.directory?(dir) }
end

.sdk_root!Object



78
79
80
81
82
# File 'lib/everywhere/android_sdk.rb', line 78

def sdk_root!
  sdk_root or UI.die!("no Android SDK — install Android Studio (it provisions the SDK, adb, " \
                      "the emulator and a JDK in one go), or point ANDROID_HOME at an " \
                      "existing SDK; looked in #{expected_sdk_root}")
end

.sdk_tool(subdir, name) ⇒ Object

A tool at its fixed spot under the SDK root, or nil.



151
152
153
154
155
# File 'lib/everywhere/android_sdk.rb', line 151

def sdk_tool(subdir, name)
  root = sdk_root or return nil

  tool_in(File.join(root, subdir), name)
end

.sdkmanagerObject



103
# File 'lib/everywhere/android_sdk.rb', line 103

def sdkmanager = cmdline_tool("sdkmanager")

.sdkmanager!Object



106
107
108
# File 'lib/everywhere/android_sdk.rb', line 106

def sdkmanager!
  sdkmanager or UI.die!("no sdkmanager under #{sdk_root!}#{cmdline_tools_hint}")
end

.studio_java_homeObject



253
254
255
# File 'lib/everywhere/android_sdk.rb', line 253

def studio_java_home
  STUDIO_JBR.map { |dir| File.expand_path(dir) }.find { |dir| File.directory?(dir) }
end

.tool_in(dir, name) ⇒ Object

Windows spells the same tools adb.exe and sdkmanager.bat; probing the suffixes here lets every caller ask for the plain Unix name.



176
177
178
179
180
# File 'lib/everywhere/android_sdk.rb', line 176

def tool_in(dir, name)
  [name, "#{name}.exe", "#{name}.bat"]
    .map { |file| File.join(dir, file) }
    .find { |path| File.file?(path) }
end

.windows?Boolean

Returns:

  • (Boolean)


47
# File 'lib/everywhere/android_sdk.rb', line 47

def windows? = RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)