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 =

What the shell template compiles against — compileSdk in support/mobile/android/app/build.gradle.kts. Named here (not in the builder) because every doctor has to print the sdkmanager line for it long before a build is attempted.

36
COMPILE_SDK_MINOR =

compileSdkMinor, or nil for a level that has no minor version. Google minor-versions SDK platforms from API 37 on: the sdkmanager package and the platforms/ directory are both "android-37.0", never "android-37", and AGP wants the minor as its own DSL property. Nothing up to 36 carries a suffix, which is why this stayed invisible until the first 37 build.

nil
TARGET_SDK =

What the shell TARGETS — targetSdk in the same file, and a different question from COMPILE_SDK: compileSdk is which APIs the shell may call, targetSdk is which OS behaviour changes it opts into. Always a plain integer; AGP has no targetSdkMinor to go with compileSdkMinor.

36
PLAY_TARGET_SDK_FLOOR =

The lowest targetSdk Google Play accepts for a NEW upload today (36 as of 2026-08-31). Play raises this every August. Bump it when Google announces the next one: android_target_sdk_test then fails until the template catches up, which is the whole point of keeping it separate from TARGET_SDK. A stale targetSdk blocks new uploads only — apps already on the store keep working and keep updating.

36
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 ---------------------------------------------------------------



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

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

.adb!Object



117
118
119
# File 'lib/everywhere/android_sdk.rb', line 117

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

.avdmanagerObject



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

def avdmanager = cmdline_tool("avdmanager")

.avdmanager!Object



132
133
134
# File 'lib/everywhere/android_sdk.rb', line 132

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.



335
336
337
338
339
340
341
342
343
344
# File 'lib/everywhere/android_sdk.rb', line 335

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



346
347
348
349
350
351
# File 'lib/everywhere/android_sdk.rb', line 346

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.



185
186
187
188
189
190
191
192
193
194
# File 'lib/everywhere/android_sdk.rb', line 185

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)


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

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.



161
162
163
# File 'lib/everywhere/android_sdk.rb', line 161

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

.cmdline_tools_hintObject



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

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.

System images are minor-versioned exactly like platforms (system-images;android-37.0;…), so they share platform_hash. Keyed to the compile level rather than TARGET_SDK because that one always names a package that exists; targetSdk carries no minor to resolve.



361
362
363
364
365
# File 'lib/everywhere/android_sdk.rb', line 361

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;#{platform_hash};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.



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

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



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

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

.emulator!Object



121
122
123
# File 'lib/everywhere/android_sdk.rb', line 121

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.



108
109
110
# File 'lib/everywhere/android_sdk.rb', line 108

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.



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

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.



166
167
168
169
170
# File 'lib/everywhere/android_sdk.rb', line 166

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.



209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/everywhere/android_sdk.rb', line 209

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



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

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.



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

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.



228
229
230
231
232
233
# File 'lib/everywhere/android_sdk.rb', line 228

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.



238
239
240
241
242
243
244
245
246
247
# File 'lib/everywhere/android_sdk.rb', line 238

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.



255
256
257
258
259
260
261
262
263
# File 'lib/everywhere/android_sdk.rb', line 255

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)


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

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

.platform!(api = platform_hash) ⇒ Object



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

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

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

.platform?(api = platform_hash) ⇒ Boolean

platform?, platform?(37) and platform?("android-37.0") all work. A full name has to match exactly, because that is the string AGP resolves and a minor is not interchangeable with its siblings — compileSdk 37 + compileSdkMinor 0 wants android-37.0 and nothing else. A bare major matches any minor of that level instead, since the caller named none. "android-36-ext18" satisfies neither: the separator is a dot.

Returns:

  • (Boolean)


312
313
314
315
316
317
# File 'lib/everywhere/android_sdk.rb', line 312

def platform?(api = platform_hash)
  name = platform_name(api)
  return installed_platforms.include?(name) if api.to_s.start_with?("android-")

  installed_platforms.any? { |p| p == name || p.start_with?("#{name}.") }
end

.platform_hash(api = COMPILE_SDK, minor = COMPILE_SDK_MINOR) ⇒ Object

How sdkmanager, AGP and the platforms/ directory all spell one platform, minor included. The single place that knows the naming rule.



300
301
302
# File 'lib/everywhere/android_sdk.rb', line 300

def platform_hash(api = COMPILE_SDK, minor = COMPILE_SDK_MINOR)
  minor.nil? ? "android-#{api}" : "android-#{api}.#{minor}"
end

.platform_name(api) ⇒ Object



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

def platform_name(api) = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"

.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.



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

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



289
290
291
292
293
294
295
296
# File 'lib/everywhere/android_sdk.rb', line 289

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.



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

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



100
101
102
103
104
# File 'lib/everywhere/android_sdk.rb', line 100

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.



173
174
175
176
177
# File 'lib/everywhere/android_sdk.rb', line 173

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

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

.sdkmanagerObject



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

def sdkmanager = cmdline_tool("sdkmanager")

.sdkmanager!Object



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

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

.studio_java_homeObject



275
276
277
# File 'lib/everywhere/android_sdk.rb', line 275

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.



198
199
200
201
202
# File 'lib/everywhere/android_sdk.rb', line 198

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)


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

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