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 —
compileSdkin support/mobile/android/app/build.gradle.kts. Named here (not in the builder) becauseevery doctorhas 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 —
targetSdkin 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
-
.adb ⇒ Object
--- tools ---------------------------------------------------------------.
- .adb! ⇒ Object
- .avdmanager ⇒ Object
- .avdmanager! ⇒ Object
-
.avds ⇒ Object
Virtual devices, newest-created last.
- .avds! ⇒ Object
-
.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. -
.cmdline_tools? ⇒ Boolean
Whether this SDK can install anything at all.
-
.cmdline_tools_dir ⇒ Object
Where an unzipped command-line-tools release has to end up.
- .cmdline_tools_hint ⇒ Object
-
.create_avd_hint ⇒ Object
How to get a bootable AVD.
-
.default_sdk_roots ⇒ Object
Where the SDK lives on a default install, per platform.
- .emulator ⇒ Object
- .emulator! ⇒ Object
-
.expected_sdk_root ⇒ Object
The path named in "we couldn't find it" messages, so the user is told where we looked rather than left to guess.
-
.host_abi ⇒ Object
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.
-
.host_tag ⇒ Object
The token Google's command-line-tools downloads are named with.
-
.install_hint(package) ⇒ Object
A ready-to-paste instruction for installing a missing SDK package.
- .installed_build_tools ⇒ Object
-
.installed_platforms ⇒ Object
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. -
.java_home ⇒ Object
Gradle needs a JDK.
-
.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.
-
.java_version ⇒ Object
The JDK's major version, or nil if it can't be determined.
-
.macos? ⇒ Boolean
--- platform ------------------------------------------------------------.
- .platform!(api = platform_hash) ⇒ Object
-
.platform?(api = platform_hash) ⇒ Boolean
platform?,platform?(37)andplatform?("android-37.0")all work. -
.platform_hash(api = COMPILE_SDK, minor = COMPILE_SDK_MINOR) ⇒ Object
How sdkmanager, AGP and the platforms/ directory all spell one platform, minor included.
- .platform_name(api) ⇒ Object
-
.registry_java_home ⇒ Object
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.
- .sdk_package_dirs(kind) ⇒ Object
-
.sdk_root ⇒ Object
The SDK root, or nil.
- .sdk_root! ⇒ Object
-
.sdk_tool(subdir, name) ⇒ Object
A tool at its fixed spot under the SDK root, or nil.
- .sdkmanager ⇒ Object
- .sdkmanager! ⇒ Object
- .studio_java_home ⇒ Object
-
.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.
- .windows? ⇒ Boolean
Class Method Details
.adb ⇒ Object
--- 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 |
.avdmanager ⇒ Object
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 |
.avds ⇒ Object
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/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.
142 |
# File 'lib/everywhere/android_sdk.rb', line 142 def cmdline_tools? = !sdkmanager.nil? |
.cmdline_tools_dir ⇒ Object
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_hint ⇒ Object
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_hint ⇒ Object
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_roots ⇒ Object
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 |
.emulator ⇒ Object
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_root ⇒ Object
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.(default_sdk_roots.first || "~/Android/Sdk") end |
.host_abi ⇒ Object
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_tag ⇒ Object
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_tools ⇒ Object
287 |
# File 'lib/everywhere/android_sdk.rb', line 287 def installed_build_tools = sdk_package_dirs("build-tools") |
.installed_platforms ⇒ Object
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_home ⇒ Object
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.(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_version ⇒ Object
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 ------------------------------------------------------------
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.
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_home ⇒ Object
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_root ⇒ Object
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.(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 |
.sdkmanager ⇒ Object
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_home ⇒ Object
275 276 277 |
# File 'lib/everywhere/android_sdk.rb', line 275 def studio_java_home STUDIO_JBR.map { |dir| File.(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
69 |
# File 'lib/everywhere/android_sdk.rb', line 69 def windows? = RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) |