ruby_everywhere 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/README.md +48 -0
  4. data/bridge/everywhere/bridge.js +388 -3
  5. data/bridge/everywhere/native.css +203 -0
  6. data/bridge/package.json +6 -3
  7. data/lib/everywhere/android_resources.rb +217 -0
  8. data/lib/everywhere/android_sdk.rb +330 -0
  9. data/lib/everywhere/asset_catalog.rb +185 -0
  10. data/lib/everywhere/auth_handoff.rb +398 -0
  11. data/lib/everywhere/auth_token.rb +118 -0
  12. data/lib/everywhere/builders/android.rb +844 -0
  13. data/lib/everywhere/builders/ios.rb +72 -3
  14. data/lib/everywhere/commands/build.rb +51 -9
  15. data/lib/everywhere/commands/clean.rb +10 -5
  16. data/lib/everywhere/commands/dev.rb +46 -8
  17. data/lib/everywhere/commands/doctor.rb +89 -3
  18. data/lib/everywhere/commands/install.rb +1 -1
  19. data/lib/everywhere/commands/logs.rb +117 -8
  20. data/lib/everywhere/config.rb +513 -14
  21. data/lib/everywhere/emulator.rb +405 -0
  22. data/lib/everywhere/engine.rb +21 -0
  23. data/lib/everywhere/icon.rb +196 -0
  24. data/lib/everywhere/log_filter.rb +74 -0
  25. data/lib/everywhere/mobile_config_endpoint.rb +13 -0
  26. data/lib/everywhere/mobile_configs_controller.rb +18 -0
  27. data/lib/everywhere/native_helper.rb +198 -5
  28. data/lib/everywhere/omniauth.rb +88 -0
  29. data/lib/everywhere/paths.rb +47 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/android/README.md +217 -0
  32. data/support/mobile/android/app/build.gradle.kts +156 -0
  33. data/support/mobile/android/app/everywhere.properties +8 -0
  34. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  35. data/support/mobile/android/app/proguard-rules.pro +21 -0
  36. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  37. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  38. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  39. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  40. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  41. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  55. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  56. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  57. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  58. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  59. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  60. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  61. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  62. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  63. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  64. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  65. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  66. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  67. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  68. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  69. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  70. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  71. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  72. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  73. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  74. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  75. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  76. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  77. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  78. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  79. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  80. data/support/mobile/android/build.gradle.kts +8 -0
  81. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  82. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  83. data/support/mobile/android/gradle.properties +14 -0
  84. data/support/mobile/android/gradlew +251 -0
  85. data/support/mobile/android/gradlew.bat +94 -0
  86. data/support/mobile/android/settings.gradle.kts +28 -0
  87. data/support/mobile/ios/App/AppDelegate.swift +7 -1
  88. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  89. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  90. data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
  91. data/support/mobile/ios/App/SceneDelegate.swift +144 -0
  92. data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
  93. data/support/mobile/ios/README.md +10 -0
  94. metadata +69 -1
data/bridge/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@rubyeverywhere/bridge",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "One API for browser, desktop, and mobile — the JS half of RubyEverywhere",
5
5
  "type": "module",
6
6
  "main": "everywhere/bridge.js",
7
- "exports": { ".": "./everywhere/bridge.js" },
8
- "files": ["everywhere/bridge.js", "README.md", "LICENSE"],
7
+ "exports": {
8
+ ".": "./everywhere/bridge.js",
9
+ "./native.css": "./everywhere/native.css"
10
+ },
11
+ "files": ["everywhere/bridge.js", "everywhere/native.css", "README.md", "LICENSE"],
9
12
  "sideEffects": false,
10
13
  "keywords": ["rubyeverywhere", "tauri", "hotwire", "turbo", "rails", "desktop", "native", "bridge"],
11
14
  "homepage": "https://rubyeverywhere.com",
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Everywhere
6
+ # Compiles an app's native/android/assets/ tree into Android drawable
7
+ # resources so native code can reach them by name — R.drawable.logo in Kotlin,
8
+ # @drawable/logo in a layout.
9
+ #
10
+ # Deliberately the same filename API as the iOS side (see AssetCatalog), so one
11
+ # assets/ tree can be authored once and dropped into both native/ios/ and
12
+ # native/android/: <name>[@2x|@3x][~dark].<ext>. What differs is where the
13
+ # markers land, because Android expresses both as directory qualifiers rather
14
+ # than as catalog metadata:
15
+ #
16
+ # logo.png → res/drawable/logo.png (density-independent baseline)
17
+ # logo@2x.png → res/drawable-xhdpi/logo.png (2x = 320dpi)
18
+ # logo@3x.png → res/drawable-xxhdpi/logo.png (3x = 480dpi)
19
+ # logo~dark.png → res/drawable-night/logo.png
20
+ # logo@2x~dark.png→ res/drawable-night-xhdpi/logo.png
21
+ #
22
+ # Sub-directories organise the source tree but do NOT namespace the result —
23
+ # res/ is flat, and asset names are global on iOS too, so assets/branding/
24
+ # logo.png is R.drawable.logo either way.
25
+ module AndroidResources
26
+ # Raster formats aapt2 accepts as-is. WebP is included (and PDF/SVG are not)
27
+ # because that is the split Android itself draws — the reverse of iOS.
28
+ RASTER = %w[.png .jpg .jpeg .webp].freeze
29
+
30
+ # A drawable written as XML: vectors, shapes, layer lists, selectors. Scale
31
+ # -free, so the density qualifiers above don't apply to them.
32
+ VECTOR = %w[.xml].freeze
33
+ EXTENSIONS = (RASTER + VECTOR).freeze
34
+
35
+ # iOS's @2x/@3x in Android's density buckets. 1x is plain `drawable`, not
36
+ # `drawable-mdpi`: both mean 160dpi, but the unqualified directory is also
37
+ # the fallback every density falls back TO, which is what a single-file
38
+ # asset needs to be.
39
+ DENSITIES = { nil => nil, "2x" => "xhdpi", "3x" => "xxhdpi" }.freeze
40
+
41
+ # Written by the Android builder itself (adaptive launcher icon layers) or
42
+ # shipped in the frozen template's own res/. Both would collide in the
43
+ # merged resource table, so they're refused by name here — a merge conflict
44
+ # from aapt2 arrives minutes into a Gradle run and names no fix.
45
+ RESERVED = %w[
46
+ ic_launcher ic_launcher_round ic_launcher_foreground ic_launcher_background
47
+ ic_tab_placeholder
48
+ ].freeze
49
+
50
+ # An Android resource entry is a field on the generated R class, so the name
51
+ # has to be a Java identifier — and aapt2 additionally rejects uppercase in
52
+ # a res/ filename. Hyphens and dots are legal on iOS and never here.
53
+ VALID_NAME = /\A[a-z][a-z0-9_]*\z/
54
+
55
+ # Reserved words can't name a field on R. `val`/`fun` are fine (Kotlin reads
56
+ # R from Java), so this is the Java list, not the Kotlin one.
57
+ JAVA_KEYWORDS = %w[
58
+ abstract assert boolean break byte case catch char class const continue default do double else
59
+ enum extends final finally float for goto if implements import instanceof int interface long
60
+ native new package private protected public return short static strictfp super switch
61
+ synchronized this throw throws transient try void volatile while
62
+ ].freeze
63
+
64
+ class << self
65
+ # Copy every image under `source` into `res` as a drawable resource.
66
+ # Returns [names, errors]; nothing is written when errors is non-empty, so
67
+ # a broken assets/ tree can't half-stamp the resource dirs.
68
+ def write!(source, res)
69
+ variants, errors = plan(source)
70
+ return [[], errors] unless errors.empty?
71
+
72
+ variants.each do |variant|
73
+ dir = File.join(res, variant[:dir])
74
+ FileUtils.mkdir_p(dir)
75
+ FileUtils.cp(variant[:path], File.join(dir, "#{variant[:name]}#{variant[:extension]}"))
76
+ end
77
+
78
+ [variants.map { |variant| variant[:name] }.uniq.sort, []]
79
+ end
80
+
81
+ private
82
+
83
+ # Errors are collected (not raised) so one `every build` reports every
84
+ # problem in the tree instead of the first.
85
+ def plan(source)
86
+ errors = []
87
+ files = Dir.glob(File.join(source, "**", "*"))
88
+ .select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
89
+ .sort
90
+ images, other = files.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
91
+
92
+ other.each { |path| errors << unsupported(path, source) }
93
+ variants = images.filter_map { |path| variant(path, source, errors) }
94
+ errors.concat(clash_errors(variants))
95
+
96
+ [variants, errors]
97
+ end
98
+
99
+ def unsupported(path, source)
100
+ relative = relative(path, source)
101
+ case File.extname(path).downcase
102
+ when ".svg", ".pdf"
103
+ "#{relative}: Android has no runtime SVG or PDF renderer — export a PNG, or convert it to " \
104
+ "a VectorDrawable (Android Studio: right-click res → New → Vector Asset) and ship the .xml"
105
+ else
106
+ "#{relative}: unsupported image type — use #{EXTENSIONS.join(", ")}"
107
+ end
108
+ end
109
+
110
+ def variant(path, source, errors)
111
+ relative = relative(path, source)
112
+ extension = File.extname(path).downcase
113
+ stem = File.basename(path, File.extname(path))
114
+
115
+ # A nine-patch is `logo.9.png`, whose stem carries a dot that no valid
116
+ # resource name may — so it would otherwise fail with a naming error
117
+ # that says nothing about what the author actually tried to do.
118
+ if stem.end_with?(".9")
119
+ errors << "#{relative}: nine-patch drawables aren't supported yet — ship a plain #{extension} " \
120
+ "(or a VectorDrawable .xml, which stretches without one)"
121
+ return nil
122
+ end
123
+
124
+ name, scale, dark = parse(stem)
125
+
126
+ unless name.match?(VALID_NAME)
127
+ errors << "#{relative}: #{name.inspect} isn't a usable Android resource name — use lowercase " \
128
+ "letters, digits and _, starting with a letter (#{suggestion(name).inspect} would " \
129
+ "work). Unlike iOS, Android has no room for capitals, hyphens or dots here"
130
+ return nil
131
+ end
132
+ if JAVA_KEYWORDS.include?(name)
133
+ errors << "#{relative}: #{name.inspect} is a Java keyword, so it can't name a field on R — " \
134
+ "rename it (#{"#{name}_image".inspect})"
135
+ return nil
136
+ end
137
+ if RESERVED.include?(name)
138
+ errors << "#{relative}: #{name} is reserved by the shell (launcher icon and tab placeholder) — " \
139
+ "rename it"
140
+ return nil
141
+ end
142
+ if scale && VECTOR.include?(extension)
143
+ errors << "#{relative}: an XML drawable is scale-free — drop the @#{scale} suffix"
144
+ return nil
145
+ end
146
+
147
+ { name: name, extension: extension, dir: directory(scale, dark), source_dir: File.dirname(relative),
148
+ scale: scale, dark: dark, vector: VECTOR.include?(extension), path: path, relative: relative }
149
+ end
150
+
151
+ # Trailing @2x/@3x and ~dark markers, in either order, off the stem —
152
+ # character for character AssetCatalog#parse, because the two platforms
153
+ # read the same filenames.
154
+ def parse(stem)
155
+ name = stem.dup
156
+ scale = nil
157
+ dark = false
158
+ loop do
159
+ if name.sub!(/~dark\z/, "")
160
+ dark = true
161
+ elsif name.sub!(/@([23])x\z/, "")
162
+ scale = "#{Regexp.last_match(1)}x"
163
+ else
164
+ break
165
+ end
166
+ end
167
+ [name, scale, dark]
168
+ end
169
+
170
+ # Qualifier order is fixed by the platform and is not alphabetical: UI mode
171
+ # (night) comes before density. `drawable-xhdpi-night` is not a directory
172
+ # Android will ever match — it's silently ignored, which reads as "my dark
173
+ # asset doesn't work" with nothing in the log.
174
+ def directory(scale, dark)
175
+ ["drawable", dark ? "night" : nil, DENSITIES.fetch(scale)].compact.join("-")
176
+ end
177
+
178
+ # What the author probably meant, so the error can show it rather than
179
+ # describe it. Not applied automatically: R.drawable.<name> is written by
180
+ # hand in Kotlin, so a name we invented is a name nobody can guess.
181
+ def suggestion(name)
182
+ candidate = name.gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase.gsub(/[^a-z0-9]+/, "_")
183
+ .gsub(/_+/, "_").delete_prefix("_").delete_suffix("_")
184
+ candidate.match?(/\A[a-z]/) ? candidate : "image_#{candidate}"
185
+ end
186
+
187
+ def clash_errors(variants)
188
+ errors = []
189
+ variants.group_by { |variant| variant[:name] }.each do |name, group|
190
+ dirs = group.map { |variant| variant[:source_dir] }.uniq
191
+ if dirs.length > 1
192
+ listed = dirs.map { |dir| dir == "." ? "assets/" : "assets/#{dir}/" }.join(" and ")
193
+ errors << "#{name}: same asset name in #{listed} — res/ is flat, so both would become " \
194
+ "R.drawable.#{name}. Rename one"
195
+ end
196
+ if group.map { |variant| variant[:vector] }.uniq.length > 1
197
+ errors << "#{name}: mixes an XML drawable with bitmaps — pick one per asset"
198
+ end
199
+
200
+ # Same destination directory and same name: aapt2 sees two files
201
+ # claiming one resource, whatever their extensions.
202
+ group.group_by { |variant| variant[:dir] }.each_value do |clash|
203
+ next if clash.length < 2
204
+
205
+ errors << "#{name}: #{clash.map { |variant| variant[:relative] }.join(" and ")} both become " \
206
+ "res/#{clash.first[:dir]}/#{name}"
207
+ end
208
+ end
209
+ errors
210
+ end
211
+
212
+ def relative(path, source)
213
+ path.delete_prefix("#{source}/")
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "shellout"
5
+ require_relative "ui"
6
+
7
+ module Everywhere
8
+ # Locates the Android toolchain — SDK, adb, emulator, sdkmanager, JDK — for
9
+ # the builder, the emulator driver, `every logs` and `every doctor`.
10
+ #
11
+ # The one rule everything here follows: resolve BY LOCATION, never off PATH.
12
+ # Android Studio provisions the whole toolchain (SDK, platform-tools, the
13
+ # emulator, system images, a bundled JDK) and exports none of it — no shell
14
+ # profile is touched, so a machine that can build Android apps all day has no
15
+ # `adb`, no `sdkmanager` and often no `java` on PATH at all. A `which adb`
16
+ # here would report "missing" on a perfectly provisioned Mac. Fixed locations
17
+ # under the SDK root are what's actually stable, the same way `xcrun` shields
18
+ # the iOS builder from wherever Xcode put its internals.
19
+ module AndroidSdk
20
+ module_function
21
+
22
+ # Android Gradle Plugin 8.x refuses to run on anything older, and the
23
+ # failure surfaces as an unreadable Gradle/Kotlin stacktrace rather than a
24
+ # version complaint — so we check before Gradle ever starts.
25
+ MINIMUM_JDK = 17
26
+
27
+ # The API level the shell template compiles against. Named here (not in the
28
+ # builder) because `every doctor` has to print the sdkmanager line for it
29
+ # long before a build is attempted.
30
+ COMPILE_SDK = 35
31
+
32
+ # Android Studio's bundled JetBrains Runtime, per platform. Studio never
33
+ # exports it, so these paths are the only way to find a JDK on a machine
34
+ # whose owner installed Studio and nothing else.
35
+ STUDIO_JBR = [
36
+ "/Applications/Android Studio.app/Contents/jbr/Contents/Home",
37
+ "~/Applications/Android Studio.app/Contents/jbr/Contents/Home",
38
+ "/opt/android-studio/jbr",
39
+ "/usr/local/android-studio/jbr",
40
+ "~/android-studio/jbr",
41
+ "C:/Program Files/Android/Android Studio/jbr"
42
+ ].freeze
43
+
44
+ # --- platform ------------------------------------------------------------
45
+
46
+ def macos? = RUBY_PLATFORM.include?("darwin")
47
+ def windows? = RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
48
+
49
+ # --- SDK root ------------------------------------------------------------
50
+
51
+ # Where the SDK lives on a default install, per platform. Studio writes
52
+ # exactly one of these and records it in its own settings — never in the
53
+ # environment — which is why the fallback matters more here than it would
54
+ # for a toolchain with an installer that edits your profile.
55
+ def default_sdk_roots
56
+ if macos?
57
+ ["~/Library/Android/sdk"]
58
+ elsif windows?
59
+ [ENV["LOCALAPPDATA"] && File.join(ENV["LOCALAPPDATA"], "Android", "Sdk")].compact
60
+ else
61
+ ["~/Android/Sdk"]
62
+ end
63
+ end
64
+
65
+ # The SDK root, or nil. An explicit env var wins so CI images and secondary
66
+ # SDKs work without moving anything; ANDROID_HOME comes first because it's
67
+ # the spelling Studio and the docs use now (ANDROID_SDK_ROOT is deprecated
68
+ # but still all over CI). A var pointing at a directory that isn't there is
69
+ # skipped rather than fatal — a stale export shouldn't hide the SDK sitting
70
+ # in the default location.
71
+ def sdk_root
72
+ [ENV["ANDROID_HOME"], ENV["ANDROID_SDK_ROOT"], *default_sdk_roots]
73
+ .reject { |dir| dir.to_s.empty? }
74
+ .map { |dir| File.expand_path(dir) }
75
+ .find { |dir| File.directory?(dir) }
76
+ end
77
+
78
+ def sdk_root!
79
+ sdk_root or UI.die!("no Android SDK — install Android Studio (it provisions the SDK, adb, " \
80
+ "the emulator and a JDK in one go), or point ANDROID_HOME at an " \
81
+ "existing SDK; looked in #{expected_sdk_root}")
82
+ end
83
+
84
+ # The path named in "we couldn't find it" messages, so the user is told
85
+ # where we looked rather than left to guess.
86
+ def expected_sdk_root
87
+ File.expand_path(default_sdk_roots.first || "~/Android/Sdk")
88
+ end
89
+
90
+ # --- tools ---------------------------------------------------------------
91
+
92
+ def adb = sdk_tool("platform-tools", "adb")
93
+ def emulator = sdk_tool("emulator", "emulator")
94
+
95
+ def adb!
96
+ adb or UI.die!("no adb under #{sdk_root!} — #{install_hint("platform-tools")}")
97
+ end
98
+
99
+ def emulator!
100
+ emulator or UI.die!("no Android emulator under #{sdk_root!} — #{install_hint("emulator")}")
101
+ end
102
+
103
+ def sdkmanager = cmdline_tool("sdkmanager")
104
+ def avdmanager = cmdline_tool("avdmanager")
105
+
106
+ def sdkmanager!
107
+ sdkmanager or UI.die!("no sdkmanager under #{sdk_root!} — #{cmdline_tools_hint}")
108
+ end
109
+
110
+ def avdmanager!
111
+ avdmanager or UI.die!("no avdmanager under #{sdk_root!} — #{cmdline_tools_hint}")
112
+ end
113
+
114
+ # Whether this SDK can install anything at all. Worth asking on its own,
115
+ # because it's the one component whose absence makes every other
116
+ # remediation unquotable: without it there is no `sdkmanager` to run, and
117
+ # the Phase 0 machine had a complete SDK, adb, the emulator, build-tools and
118
+ # an AVD with no cmdline-tools anywhere — not in the SDK, not inside Android
119
+ # Studio. So a missing one is an advisory, not a broken machine.
120
+ def cmdline_tools? = !sdkmanager.nil?
121
+
122
+ # How to get command-line tools without already having them. The Studio
123
+ # route is first because it also accepts the licences, but the manual zip
124
+ # matters: it's the only answer for a machine with no Studio, and Studio's
125
+ # own SDK Tools list is exactly where this component was already unchecked.
126
+ CMDLINE_TOOLS_URL = "https://developer.android.com/studio#command-line-tools-only"
127
+
128
+ def cmdline_tools_hint
129
+ "install \"Android SDK Command-line Tools (latest)\" in Android Studio → Settings → " \
130
+ "Languages & Frameworks → Android SDK → SDK Tools, or download " \
131
+ "commandlinetools-#{host_tag}-*_latest.zip from #{CMDLINE_TOOLS_URL} and unzip it into " \
132
+ "#{cmdline_tools_dir} (so its bin/ lands there)"
133
+ end
134
+
135
+ # Where an unzipped command-line-tools release has to end up. Named because
136
+ # both hints point at it, and because "unzip it somewhere in the SDK" is
137
+ # the instruction people get wrong — the tools look for their own jars
138
+ # relative to this exact directory.
139
+ def cmdline_tools_dir
140
+ File.join(sdk_root || expected_sdk_root, "cmdline-tools", "latest")
141
+ end
142
+
143
+ # The token Google's command-line-tools downloads are named with.
144
+ def host_tag
145
+ return "mac" if macos?
146
+
147
+ windows? ? "win" : "linux"
148
+ end
149
+
150
+ # A tool at its fixed spot under the SDK root, or nil.
151
+ def sdk_tool(subdir, name)
152
+ root = sdk_root or return nil
153
+
154
+ tool_in(File.join(root, subdir), name)
155
+ end
156
+
157
+ # sdkmanager and avdmanager are the one pair that moved: current installs
158
+ # put them in cmdline-tools/latest/bin, an SDK pinned to a specific
159
+ # command-line-tools release uses cmdline-tools/<version>/bin, and anything
160
+ # provisioned before 2021 still has the legacy tools/bin copy. Try all three
161
+ # so `every doctor` can print a working install line on an older machine
162
+ # instead of a path that doesn't exist.
163
+ def cmdline_tool(name)
164
+ root = sdk_root or return nil
165
+
166
+ # Reverse-sorted so 12.0 beats 9.0 in the common case; "latest" is tried
167
+ # first anyway, so this ordering only decides between pinned releases.
168
+ versioned = Dir.glob(File.join(root, "cmdline-tools", "*", "bin")).sort.reverse
169
+ dirs = [File.join(root, "cmdline-tools", "latest", "bin"), *versioned,
170
+ File.join(root, "tools", "bin")]
171
+ dirs.uniq.filter_map { |dir| tool_in(dir, name) }.first
172
+ end
173
+
174
+ # Windows spells the same tools adb.exe and sdkmanager.bat; probing the
175
+ # suffixes here lets every caller ask for the plain Unix name.
176
+ def tool_in(dir, name)
177
+ [name, "#{name}.exe", "#{name}.bat"]
178
+ .map { |file| File.join(dir, file) }
179
+ .find { |path| File.file?(path) }
180
+ end
181
+
182
+ # A ready-to-paste instruction for installing a missing SDK package. When
183
+ # the SDK has no command-line tools at all there IS no command to paste, so
184
+ # say what to click in Studio instead — an honest instruction beats a
185
+ # copyable line that resolves to nothing. Phrased to read on its own, since
186
+ # callers append it after an em dash.
187
+ def install_hint(package)
188
+ if (manager = sdkmanager)
189
+ "run: #{manager.shellescape} #{package.inspect}"
190
+ else
191
+ # The short form of cmdline_tools_hint: doctor prints the full version
192
+ # once, above these lines, and repeating a URL and two absolute paths
193
+ # on every missing package buries what each line is actually about.
194
+ "install \"Android SDK Command-line Tools\" (Android Studio → SDK Tools, or unzip " \
195
+ "commandlinetools-#{host_tag}-*_latest.zip into #{cmdline_tools_dir}), " \
196
+ "then run: sdkmanager #{package.inspect}"
197
+ end
198
+ end
199
+
200
+ # --- JDK -----------------------------------------------------------------
201
+
202
+ # Gradle needs a JDK. JAVA_HOME wins because it's the one explicit choice on
203
+ # the machine; then macOS's own registry (which knows about JDKs installed
204
+ # by brew, Oracle or SDKMAN without any of them being on PATH); then
205
+ # Studio's bundled runtime, which is what a Studio-only machine actually has.
206
+ def java_home
207
+ explicit = ENV["JAVA_HOME"].to_s
208
+ return File.expand_path(explicit) if !explicit.empty? && File.directory?(explicit)
209
+
210
+ registry_java_home || studio_java_home
211
+ end
212
+
213
+ # Like #java_home but also enforces the minimum: a JDK 11 that resolves
214
+ # cleanly still fails the build later, deep inside Gradle, so reject it here
215
+ # where the message can name the cause.
216
+ def java_home!(minimum: MINIMUM_JDK)
217
+ home = java_home or
218
+ UI.die!("no JDK — install Android Studio (it bundles one at #{STUDIO_JBR.first}) " \
219
+ "or set JAVA_HOME to a JDK #{minimum} or newer")
220
+ version = java_version
221
+ return home if version.nil? || version >= minimum
222
+
223
+ UI.die!("#{home} is JDK #{version}; the Android Gradle Plugin needs #{minimum} or newer — " \
224
+ "set JAVA_HOME to a JDK #{minimum}+, or install Android Studio and use its bundled runtime")
225
+ end
226
+
227
+ # The JDK's major version, or nil if it can't be determined. Read from the
228
+ # `release` file the JDK ships rather than by running `java -version`: it's
229
+ # a file read instead of a JVM start, and it works for a JDK that isn't on
230
+ # PATH — which, per the rule at the top, is most of them. A JDK 8 reports
231
+ # JAVA_VERSION="1.8.0_412", so this yields 1, which compares correctly
232
+ # against MINIMUM_JDK without special-casing the old scheme.
233
+ def java_version
234
+ home = java_home or return nil
235
+ release = File.join(home, "release")
236
+ return File.read(release)[/^JAVA_VERSION="?(\d+)/, 1]&.to_i if File.file?(release)
237
+
238
+ java = tool_in(File.join(home, "bin"), "java") or return nil
239
+ out, status = Shellout.capture(java, "-version")
240
+ out[/version "?(\d+)/, 1]&.to_i if status&.success?
241
+ end
242
+
243
+ # macOS keeps a registry of installed JDKs that java_home queries; -v 17
244
+ # asks for "17 or newer", so a machine with only JDK 21 still answers.
245
+ def registry_java_home
246
+ return nil unless macos?
247
+
248
+ out, status = Shellout.capture("/usr/libexec/java_home", "-v", MINIMUM_JDK.to_s)
249
+ home = out.to_s.strip
250
+ home if status&.success? && !home.empty? && File.directory?(home)
251
+ end
252
+
253
+ def studio_java_home
254
+ STUDIO_JBR.map { |dir| File.expand_path(dir) }.find { |dir| File.directory?(dir) }
255
+ end
256
+
257
+ # --- installed packages --------------------------------------------------
258
+
259
+ # Which platforms/build-tools are on disk, read straight off the filesystem
260
+ # rather than from `sdkmanager --list`: that's a multi-second JVM start that
261
+ # also reaches out for the remote catalogue we don't care about, and it
262
+ # needs the command-line tools, which are exactly the component most likely
263
+ # to be missing on a Studio-only machine.
264
+ def installed_platforms = sdk_package_dirs("platforms")
265
+ def installed_build_tools = sdk_package_dirs("build-tools")
266
+
267
+ def sdk_package_dirs(kind)
268
+ root = sdk_root or return []
269
+
270
+ Dir.glob(File.join(root, kind, "*"))
271
+ .select { |dir| File.directory?(dir) }
272
+ .map { |dir| File.basename(dir) }
273
+ .sort
274
+ end
275
+
276
+ # `platform?(35)` and `platform?("android-35")` both work — callers spell it
277
+ # whichever way reads better where they are.
278
+ def platform?(api = COMPILE_SDK)
279
+ name = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"
280
+ installed_platforms.include?(name)
281
+ end
282
+
283
+ def platform!(api = COMPILE_SDK)
284
+ return true if platform?(api)
285
+
286
+ package = "platforms;android-#{api.to_s.delete_prefix("android-")}"
287
+ UI.die!("the Android SDK has no #{package} (installed: " \
288
+ "#{installed_platforms.empty? ? "none" : installed_platforms.join(", ")}) — " \
289
+ "#{install_hint(package)}")
290
+ end
291
+
292
+ # --- AVDs ----------------------------------------------------------------
293
+
294
+ # Virtual devices, newest-created last. Asked of the emulator binary rather
295
+ # than globbed out of ~/.android/avd because the emulator also honours
296
+ # ANDROID_AVD_HOME and ANDROID_SDK_HOME — and an AVD it won't list is one we
297
+ # couldn't boot anyway. Studio's Device Manager writes to the same place, so
298
+ # a VM created in the GUI shows up here with no extra step.
299
+ def avds
300
+ exe = emulator or return []
301
+
302
+ out, status = Shellout.capture(exe, "-list-avds")
303
+ return [] unless status&.success?
304
+
305
+ # The emulator prints INFO/WARNING banners on the same stream; AVD names
306
+ # can only contain [A-Za-z0-9._-], so anything with a space is chatter.
307
+ out.lines.map(&:strip).reject(&:empty?).grep(/\A[\w.-]+\z/)
308
+ end
309
+
310
+ def avds!
311
+ found = avds
312
+ return found if found.any?
313
+
314
+ UI.die!("no Android virtual devices — #{create_avd_hint}")
315
+ end
316
+
317
+ # How to get a bootable AVD. The GUI route comes first because a machine
318
+ # with Studio installed almost always has a device defined already, and
319
+ # Device Manager also downloads the system image the CLI line assumes.
320
+ def create_avd_hint
321
+ "create one in Android Studio → Device Manager, or run: " \
322
+ "#{(avdmanager || "avdmanager").shellescape} create avd -n Pixel_API_#{COMPILE_SDK} " \
323
+ "-k \"system-images;android-#{COMPILE_SDK};google_apis;#{host_abi}\""
324
+ end
325
+
326
+ # The system image ABI that runs at native speed on this host — an x86_64
327
+ # image on Apple silicon boots, technically, at a speed nobody will wait for.
328
+ def host_abi = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "arm64-v8a" : "x86_64"
329
+ end
330
+ end