ruby_everywhere 0.3.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,844 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "set"
5
+ require_relative "../paths"
6
+ require_relative "../shellout"
7
+ require_relative "../log_filter"
8
+ require_relative "../icon"
9
+ require_relative "../android_resources"
10
+ require_relative "../android_sdk"
11
+ require_relative "../version"
12
+ require_relative "../ui"
13
+
14
+ module Everywhere
15
+ module Builders
16
+ # Assembles a Hotwire Native Android app around an already-deployed (remote
17
+ # mode) app — the Android half of Builders::Ios, and deliberately the same
18
+ # five steps: copy the gem's frozen Gradle template, stamp an explicit list
19
+ # of per-app files (never a build script), and hand it to Gradle.
20
+ #
21
+ # The stamped list, and nothing else:
22
+ #
23
+ # app/everywhere.properties identity (app/build.gradle.kts reads it)
24
+ # app/native-packages.gradle.kts native.android.packages
25
+ # app/src/stamped/AndroidManifest.xml deep links, auth scheme, permissions
26
+ # app/src/main/assets/everywhere.json Config#to_shell_json
27
+ # app/src/main/assets/json/path-configuration.json baked tabs + rules
28
+ # app/src/main/assets/fonts/ the selected Material icon font
29
+ # app/src/main/res/values/colors.xml tint / background, light (replaced in place)
30
+ # app/src/main/res/values-night/colors.xml tint / background, dark
31
+ # app/src/main/res/mipmap-*/ launcher icon layers + anydpi-v26
32
+ # app/src/stamped/res/drawable*/ native/android/assets/** as drawables
33
+ # app/src/main/java/com/rubyeverywhere/shell/extensions/ native/android/*.kt + registry
34
+ #
35
+ # MVP scope is a debug/unsigned-release APK. Keystore signing, AAB and Play
36
+ # upload hang off the same seam later — see docs/android-plan.md §9.
37
+ class Android
38
+ # Fixed by the template contract: one Gradle module, always :app, so
39
+ # build paths and task names never depend on per-app values.
40
+ GRADLE_MODULE = ":app"
41
+
42
+ # The Kotlin package the shell's sources live in. NOT the applicationId:
43
+ # the app id is stamped per app, the code's package is frozen, and mixing
44
+ # them up is the classic Android manifest mistake — so anything we
45
+ # generate that names a class spells the package out in full.
46
+ SHELL_PACKAGE = "com.rubyeverywhere.shell"
47
+ EXTENSIONS_PACKAGE = "#{SHELL_PACKAGE}.extensions"
48
+
49
+ # The activity every stamped intent-filter attaches to. Its <activity>
50
+ # element already exists in the frozen main manifest; the stamped source
51
+ # set only adds filters to it.
52
+ MAIN_ACTIVITY = "#{SHELL_PACKAGE}.MainActivity"
53
+
54
+ # google/material-design-icons, pinned by commit for the same reason the
55
+ # iOS side pins Package.resolved: an icon set that silently changes under
56
+ # a cached build is a rendering bug nobody can reproduce. Each entry
57
+ # carries the SHA-256 of both files, so a corrupted or substituted
58
+ # download fails loudly instead of shipping.
59
+ ICON_FONT_COMMIT = "528cb964c01fb2b09bc3b9208f82b6d8f8c1c1e2"
60
+ ICON_FONT_BASE = "https://raw.githubusercontent.com/google/material-design-icons"
61
+
62
+ # Keyed by the asset filename Config::ANDROID_ICON_FONTS names; "path" is
63
+ # the repo path without an extension (upstream spells the variable fonts
64
+ # with their axis list in brackets — we ship them under the plain name the
65
+ # shell asks Typeface for).
66
+ FETCHED_ICON_FONTS = {
67
+ "MaterialSymbolsOutlined.ttf" => {
68
+ "path" => "variablefont/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght]",
69
+ "ttf" => "b5126c4655e0756f334d684104156b98b82ef7d61212a81c041b9e6cfa8ba925",
70
+ "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
71
+ },
72
+ "MaterialSymbolsRounded.ttf" => {
73
+ "path" => "variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght]",
74
+ "ttf" => "8817f9df195b582d45a6031c1d9e1533595dd7a6f0e76abc309bdc3cc4d72ee1",
75
+ "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
76
+ },
77
+ "MaterialSymbolsSharp.ttf" => {
78
+ "path" => "variablefont/MaterialSymbolsSharp[FILL,GRAD,opsz,wght]",
79
+ "ttf" => "c56a37227b580aa30bcbf352a770a2598dc86bca43bf060ff8d0550c081827e6",
80
+ "codepoints" => "8567a3d0512ce8a735a739d325e83ee7010d9bf7f6f8ed6a699c07c817ea907d"
81
+ }
82
+ }.freeze
83
+
84
+ # Where the bundled (classic) font sits inside the template. Two spellings
85
+ # because the template ships it as the default asset, and a template that
86
+ # keeps its fonts out of assets/ still resolves.
87
+ BUNDLED_FONT_DIRS = [%w[app src main assets fonts], %w[fonts]].freeze
88
+
89
+ # Marks which template a work dir was staged from. A gem upgrade can add,
90
+ # rename or delete template files, and staging merges rather than wipes
91
+ # (see #stage), so a stale Kotlin file from an older template would keep
92
+ # compiling in. Version mismatch = wipe once, then merge as usual.
93
+ STAGE_MARKER = ".everywhere-template"
94
+
95
+ # What AGP accepts as an applicationId: two or more segments, each a Java
96
+ # identifier starting with a letter. No hyphens, unlike a bundle id.
97
+ APPLICATION_ID = /\A[a-zA-Z]\w*(\.[a-zA-Z]\w*)+\z/
98
+
99
+ def initialize(config:, root:, target:, template_dir: nil)
100
+ @config = config
101
+ @root = root
102
+ @target = target
103
+ @template_dir = template_dir
104
+ end
105
+
106
+ # Build the APK and copy it into dist_dir. `dev` builds the debug variant
107
+ # and relaxes the remote-mode requirement (the dev URL is stamped into a
108
+ # debug asset each run — Android has no SIMCTL_CHILD_* equivalent).
109
+ # Returns the path of the collected APK.
110
+ def build!(dist_dir:, dev: false, dev_url: nil)
111
+ # The template is resolved before preflight, not after it like iOS:
112
+ # preflight has to settle the icon font, and the bundled font lives in
113
+ # the template. Everything that can fail slowly (a font download) or
114
+ # cheaply (a typo'd icon name) is therefore decided before Gradle — the
115
+ # difference between a 5-second error and one that lands 90 seconds in.
116
+ template = @template_dir ? File.expand_path(@template_dir) : Paths.android_dir!
117
+ preflight!(dev: dev, template: template)
118
+ work = stage(template)
119
+ stamp!(work)
120
+ stamp_dev_url!(work, dev_url)
121
+ variant = dev ? "debug" : "release"
122
+ gradle!(work, variant, dist_dir)
123
+ collect(work, variant, dist_dir)
124
+ end
125
+
126
+ private
127
+
128
+ def name = @config.name(target: @target)
129
+ def application_id = @config.bundle_id(target: @target)
130
+
131
+ # The dev-server URL, for the debug source set only (EverywhereConfig's
132
+ # devUrl reads it, and release builds never look). This is the whole of
133
+ # Android's answer to SIMCTL_CHILD_EVERYWHERE_DEV_URL: there is no launch
134
+ # environment to read, so the URL is baked — which also makes it survive a
135
+ # relaunch from the launcher, the case iOS papers over with UserDefaults.
136
+ #
137
+ # Always rewritten, and REMOVED when there's no dev URL: a stale file from
138
+ # yesterday's `every dev` would otherwise quietly point a plain
139
+ # `every build --android` debug APK at a laptop that isn't listening.
140
+ def stamp_dev_url!(work, dev_url)
141
+ asset = File.join(work, "app", "src", "debug", "assets", "dev-url.txt")
142
+ unless dev_url
143
+ FileUtils.rm_f(asset)
144
+ return
145
+ end
146
+
147
+ FileUtils.mkdir_p(File.dirname(asset))
148
+ File.write(asset, "#{dev_url}\n")
149
+ UI.step("pointing the shell at #{UI.cyan(dev_url)}")
150
+ end
151
+
152
+ def preflight!(dev:, template:)
153
+ # Config shape first, machine state second: a typo in everywhere.yml is
154
+ # the app author's to fix wherever they are, while "no Android SDK"
155
+ # depends on the machine they happen to be sitting at.
156
+ errors = @config.native_android_errors
157
+ UI.die!("native.android in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
158
+
159
+ unless dev || @config.remote?
160
+ UI.die!("Android apps are remote mode only for now — set remote.url in config/everywhere.yml " \
161
+ "(the shell wraps your deployed app; nothing is packaged locally)")
162
+ end
163
+
164
+ # AGP rejects an applicationId that isn't two-or-more dot-separated
165
+ # Java identifiers — and the default derived from the app's name
166
+ # ("com.rubyeverywhere.my-notes") is exactly the shape it rejects,
167
+ # because a hyphen is legal in a macOS bundle id and never in a
168
+ # package name. Caught here so the answer is a config key, not a
169
+ # Gradle stacktrace.
170
+ unless application_id.match?(APPLICATION_ID)
171
+ UI.die!("#{application_id.inspect} can't be an Android applicationId — it needs two or " \
172
+ "more dot-separated segments of letters, digits and _, each starting with a " \
173
+ "letter. Set platforms.android.bundle_id in config/everywhere.yml")
174
+ end
175
+
176
+ # Resolve by location, never off PATH: Android Studio provisions the
177
+ # whole toolchain and exports none of it, so `which adb` reports a
178
+ # broken machine that in fact builds fine. Each of these names its own
179
+ # fix — the analogue of the iOS xcode-select message.
180
+ @sdk_root = AndroidSdk.sdk_root!
181
+ @java_home = AndroidSdk.java_home!
182
+ AndroidSdk.adb!
183
+ # A missing compile SDK surfaces from Gradle as "Failed to find target
184
+ # with hash string android-35", minutes in and after the AGP download.
185
+ AndroidSdk.platform!
186
+ report_tab_overflow!
187
+ resolve_icon_font!(template)
188
+ end
189
+
190
+ # Copy the template into the app's persistent work dir. Merged over
191
+ # whatever is already there, NOT wiped like the iOS stage: Gradle's
192
+ # incremental state (app/build/, .gradle/) lives *inside* this directory,
193
+ # and a cold build is 1m37s against a warm one's 7s. The two dirs the
194
+ # stamp regenerates wholesale (extensions/, assets/fonts/) are cleared by
195
+ # their own writers, so nothing accumulates there either.
196
+ def stage(template)
197
+ work = Paths.android_work_dir(application_id)
198
+ FileUtils.rm_rf(work) unless staged_version(work) == Everywhere::VERSION
199
+ FileUtils.mkdir_p(work)
200
+ FileUtils.cp_r(File.join(template, "."), work)
201
+ # A gem install doesn't reliably preserve the executable bit, and a
202
+ # non-executable gradlew fails with a bare "Permission denied".
203
+ wrapper = File.join(work, "gradlew")
204
+ FileUtils.chmod("u+x", wrapper) if File.file?(wrapper)
205
+ File.write(File.join(work, STAGE_MARKER), "#{Everywhere::VERSION}\n")
206
+ work
207
+ end
208
+
209
+ def staged_version(work)
210
+ marker = File.join(work, STAGE_MARKER)
211
+ File.read(marker).strip if File.file?(marker)
212
+ end
213
+
214
+ # The whole per-app surface: identity properties, two JSON assets, colors,
215
+ # launcher icons, the icon font, the merged-in manifest, the Extensions
216
+ # sources + generated registry, and the Maven coordinate script. No build
217
+ # script and no layout is ever touched — that half of the freeze contract
218
+ # is what lets the template be tested standalone.
219
+ def stamp!(work)
220
+ UI.step("stamping shell #{UI.dim("(#{application_id} → #{UI.short_path(work)})")}")
221
+ write_properties(work)
222
+ assets = File.join(work, "app", "src", "main", "assets")
223
+ FileUtils.mkdir_p(File.join(assets, "json"))
224
+ File.write(File.join(assets, "everywhere.json"), @config.to_shell_json(target: @target))
225
+ # Same document MobileConfigEndpoint serves live — this baked copy is
226
+ # the offline/first-launch fallback the shell loads synchronously.
227
+ File.write(File.join(assets, "json", "path-configuration.json"),
228
+ @config.path_configuration_json("android"))
229
+ write_colors(work)
230
+ write_launcher_icons(work)
231
+ write_drawables(work)
232
+ write_icon_font(work)
233
+ write_manifest(work)
234
+ write_extensions(work)
235
+ write_native_packages(work)
236
+ end
237
+
238
+ # --- identity -------------------------------------------------------------
239
+
240
+ # app/build.gradle.kts reads these four values, so identity is stamped
241
+ # without ever editing a build script — the direct analogue of the iOS
242
+ # App.xcconfig. The `everywhere.` prefix is the template's namespace:
243
+ # its stamped(key) helper looks up "everywhere.$key", and the label
244
+ # travels on to the frozen manifest as a placeholder.
245
+ def write_properties(work)
246
+ File.write(File.join(work, "app", "everywhere.properties"), <<~PROPERTIES)
247
+ # Written by `every build --target #{@target}` — do not edit; changes
248
+ # belong in config/everywhere.yml.
249
+ everywhere.applicationId=#{properties_value(application_id)}
250
+ everywhere.versionName=#{properties_value(@config.version(target: @target))}
251
+ everywhere.versionCode=#{version_code}
252
+ everywhere.label=#{properties_value(name)}
253
+ PROPERTIES
254
+ end
255
+
256
+ # Play rejects an upload whose versionCode isn't higher than the last, so
257
+ # deriving it from the version the app already maintains beats asking for
258
+ # a second number nobody remembers to bump. 1.2.3 → 10203, which stays
259
+ # monotonic as long as minor/patch stay under 100.
260
+ def version_code
261
+ major, minor, patch = @config.version(target: @target).to_s.split(".").map(&:to_i)
262
+ code = ((major || 0) * 10_000) + ((minor || 0) * 100) + (patch || 0)
263
+ [code, 1].max
264
+ end
265
+
266
+ # java.util.Properties reads a byte stream as ISO-8859-1 and treats
267
+ # `=`, `:`, `#`, `!` and backslash as syntax — so an app called
268
+ # "Café: Notes" would arrive mangled or split. \uXXXX escapes are the one
269
+ # spelling that survives both, whatever charset the build script reads with.
270
+ def properties_value(value)
271
+ value.to_s.gsub(/[\\=:#!]/) { |c| "\\#{c}" }
272
+ .gsub(/[^\x20-\x7E]/) { |c| format("\\u%04X", c.ord) }
273
+ end
274
+
275
+ # --- resources ------------------------------------------------------------
276
+
277
+ # appearance.tint_color / background_color → the two colors the frozen
278
+ # themes derive everything else from, in both appearances: the light half
279
+ # in values/colors.xml, the dark half in values-night/colors.xml, under
280
+ # the same resource names (that IS the mechanism — the resource system
281
+ # picks the file, not the name).
282
+ #
283
+ # Values are replaced *in place* rather than the files being rewritten
284
+ # (the Info.plist plutil -replace pattern), because each file also carries
285
+ # derived colors — on_tint, error, transparent — that are the template's
286
+ # to own, and the template's own defaults are the right answer for
287
+ # anything the app didn't declare.
288
+ def write_colors(work)
289
+ tint = @config.tint_color
290
+ background = @config.background_color
291
+ return if tint.nil? && background.nil?
292
+
293
+ values = File.join(work, "app", "src", "main", "res")
294
+ { "values" => "light", "values-night" => "dark" }.each do |dir, appearance|
295
+ colors = { "everywhere_tint" => tint&.fetch(appearance, nil),
296
+ "everywhere_background" => background&.fetch(appearance, nil) }
297
+
298
+ path = File.join(values, dir, "colors.xml")
299
+ xml = File.file?(path) ? File.read(path) : "<resources>\n</resources>\n"
300
+ colors.each do |resource, value|
301
+ hex = Icon.hex_rgb(value) or next
302
+
303
+ xml = replace_color(xml, resource, format("#%02X%02X%02X", *hex))
304
+ end
305
+ FileUtils.mkdir_p(File.dirname(path))
306
+ File.write(path, xml)
307
+ end
308
+ end
309
+
310
+ def replace_color(xml, resource, hex)
311
+ pattern = %r{(<color\s+name="#{Regexp.escape(resource)}"[^>]*>)[^<]*(</color>)}
312
+ return xml.sub(pattern, "\\1#{hex}\\2") if xml.match?(pattern)
313
+
314
+ xml.sub(%r{</resources>}, " <color name=\"#{resource}\">#{hex}</color>\n</resources>")
315
+ end
316
+
317
+ def write_launcher_icons(work)
318
+ source = @config.icon
319
+ unless source && File.exist?(source)
320
+ UI.warn "no app icon (set app.icon or add icon.png) — shipping the placeholder"
321
+ return
322
+ end
323
+
324
+ background = Icon.hex_rgb(@config.background_color&.fetch("light", nil))
325
+ res = File.join(work, "app", "src", "main", "res")
326
+ return if Icon.write_android_mipmaps(source, res, background: background)
327
+
328
+ UI.warn "couldn't read #{File.basename(source)} — shipping the placeholder icon"
329
+ end
330
+
331
+ # App-supplied images: native/android/assets/** → drawable resources, so
332
+ # native/android Kotlin reaches them by name with R.drawable.logo. The iOS
333
+ # analogue is write_asset_catalog, and the filename API is identical.
334
+ #
335
+ # They land in their own source set (src/stamped/res, registered as a res
336
+ # srcDir by the frozen build script) rather than in main/res, for the
337
+ # reason the manifest does: the whole directory can be regenerated
338
+ # wholesale — an asset the app deleted must stop shipping — and staging
339
+ # merges rather than wipes, so anything written into main/res would
340
+ # survive its source file being removed. Cleared unconditionally, before
341
+ # the early return, so dropping the assets/ dir entirely also takes
342
+ # yesterday's drawables with it.
343
+ def write_drawables(work)
344
+ dest = File.join(work, "app", "src", "stamped", "res")
345
+ FileUtils.rm_rf(dest)
346
+
347
+ source = File.join(@root, "native", "android", "assets")
348
+ return unless File.directory?(source)
349
+
350
+ names, errors = AndroidResources.write!(source, dest)
351
+ UI.die!("native/android/assets:\n#{errors.join("\n")}") unless errors.empty?
352
+ return if names.empty?
353
+
354
+ listed = names.length > 6 ? "#{names.take(6).join(", ")}, …" : names.join(", ")
355
+ UI.step("bundling image assets #{UI.dim("(#{names.length} from native/android/assets: #{listed})")}")
356
+ end
357
+
358
+ # --- tabs -----------------------------------------------------------------
359
+
360
+ # Material's bottom navigation holds five destinations and no more, and
361
+ # unlike iOS's UITabBarController it synthesises nothing past that. The
362
+ # shell hand-builds the analogue: from the sixth tab on, the last bar slot
363
+ # becomes "More" and everything from the fifth tab onwards opens from a
364
+ # sheet behind it, badges included (More's badge is their sum).
365
+ BAR_SLOTS = 5
366
+
367
+ # Slots left for tabs of their own once More claims one.
368
+ BAR_TABS_WITH_MORE = BAR_SLOTS - 1
369
+
370
+ # Where a More sheet stops being a menu and starts being a scroll. Well
371
+ # clear of the ten a large app plausibly declares: the point is to catch a
372
+ # tabs: list that grew by accident, not to second-guess a deliberate one.
373
+ CROWDED_TABS = 15
374
+
375
+ # Nothing is dropped, so this is a note about layout rather than a
376
+ # warning about loss — but the layout is different enough from iOS's, and
377
+ # from what the config looks like, to be worth stating before a device
378
+ # shows it.
379
+ def report_tab_overflow!
380
+ tabs = @config.tabs_for("android")
381
+ return if tabs.length <= BAR_SLOTS
382
+
383
+ overflow = tabs.drop(BAR_TABS_WITH_MORE).map { |tab| tab["title"] }
384
+ UI.detail "#{tabs.length} tabs: Android's bar holds #{BAR_TABS_WITH_MORE} plus a \"More\" tab — " \
385
+ "#{overflow.map(&:inspect).join(", ")} open from there (iOS does the same past five)"
386
+
387
+ return if tabs.length <= CROWDED_TABS
388
+
389
+ UI.warn "#{overflow.length} tabs behind \"More\" is a long list to scroll on a phone — " \
390
+ "consider moving the rarer ones into the app's own navigation"
391
+ end
392
+
393
+ # --- icon font ------------------------------------------------------------
394
+
395
+ # Settle which font ships and prove every declared icon name exists in it,
396
+ # before anything slow starts. Two failure modes this ordering exists for:
397
+ # a fetch that can't reach the network (say so now, name the offline
398
+ # answer) and a typo'd icon name (fails here naming the tab, instead of
399
+ # rendering a blank icon on a device).
400
+ def resolve_icon_font!(template)
401
+ file = @config.native_android_icon_font_file
402
+ @icon_font = nil
403
+ return unless file # icon_font: none — the app brings its own drawables
404
+
405
+ @icon_font = if @config.native_android_icon_font_fetched?
406
+ fetch_icon_font!(file)
407
+ else
408
+ bundled_icon_font!(template, file)
409
+ end
410
+ validate_icon_names!(@icon_font)
411
+ end
412
+
413
+ def bundled_icon_font!(template, file)
414
+ codepoints = @config.native_android_icon_font_codepoints
415
+ dir = BUNDLED_FONT_DIRS.map { |parts| File.join(template, *parts) }
416
+ .find { |candidate| File.file?(File.join(candidate, file)) }
417
+ unless dir
418
+ UI.die!("the Android template has no #{file} — reinstall ruby_everywhere, or set " \
419
+ "native.android.icon_font: symbols in config/everywhere.yml to fetch one instead")
420
+ end
421
+
422
+ { "file" => file, "ttf" => File.join(dir, file), "codepoints" => File.join(dir, codepoints) }
423
+ end
424
+
425
+ # The Symbols fonts are 8.8–15 MB, which is why they aren't in a 229 KB
426
+ # gem: they're fetched once and reused by every later build of every app.
427
+ # Existence at the cache path IS the proof of integrity — #fetch! renames
428
+ # into place only after the SHA-256 matches, so a half-finished or
429
+ # tampered download never becomes the cache, and no build after the first
430
+ # pays to re-hash 10 MB.
431
+ def fetch_icon_font!(file)
432
+ pin = FETCHED_ICON_FONTS.fetch(file)
433
+ dir = Paths.android_fonts_dir
434
+ ttf = File.join(dir, file)
435
+ codepoints = File.join(dir, @config.native_android_icon_font_codepoints)
436
+ resolved = { "file" => file, "ttf" => ttf, "codepoints" => codepoints }
437
+ return resolved if File.file?(ttf) && File.file?(codepoints)
438
+
439
+ FileUtils.mkdir_p(dir)
440
+ UI.step("fetching the #{@config.native_android_icon_font} icon font " \
441
+ "#{UI.dim("(one time, cached in #{UI.short_path(dir)})")}")
442
+ self.class.fetch!(icon_font_url(pin["path"], ".ttf"), ttf, sha256: pin["ttf"])
443
+ self.class.fetch!(icon_font_url(pin["path"], ".codepoints"), codepoints, sha256: pin["codepoints"])
444
+ resolved
445
+ rescue StandardError => e
446
+ UI.die!("couldn't fetch the #{@config.native_android_icon_font} icon font (#{e.message}) — " \
447
+ "set native.android.icon_font: classic in config/everywhere.yml to use the 2,235-icon " \
448
+ "set bundled in the gem, which needs no download and works offline")
449
+ end
450
+
451
+ # The upstream filenames carry their variable-axis list in brackets —
452
+ # "MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf". Percent-encoded by
453
+ # hand rather than through URI.escape, whose default unsafe set leaves
454
+ # brackets and commas alone: those are exactly the three characters here.
455
+ def icon_font_url(path, extension)
456
+ encoded = "#{path}#{extension}".gsub(/[\[\],]/) { |char| format("%%%02X", char.ord) }
457
+ "#{ICON_FONT_BASE}/#{ICON_FONT_COMMIT}/#{encoded}"
458
+ end
459
+
460
+ # Download to a sibling .part file, verify, then rename — the atomicity
461
+ # the cache-hit rule above depends on. A class method so tests (and the
462
+ # offline path) have one seam to stub instead of a live socket.
463
+ def self.fetch!(url, dest, sha256:, redirects: 3)
464
+ require "net/http"
465
+ require "uri"
466
+ require "digest"
467
+
468
+ uri = URI.parse(url)
469
+ part = "#{dest}.part"
470
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
471
+ http.request(Net::HTTP::Get.new(uri)) do |response|
472
+ case response
473
+ when Net::HTTPRedirection
474
+ raise "too many redirects for #{url}" if redirects <= 0
475
+
476
+ return fetch!(response["location"], dest, sha256: sha256, redirects: redirects - 1)
477
+ when Net::HTTPSuccess
478
+ File.open(part, "wb") { |f| response.read_body { |chunk| f.write(chunk) } }
479
+ else
480
+ raise "HTTP #{response.code} for #{url}"
481
+ end
482
+ end
483
+ end
484
+
485
+ digest = Digest::SHA256.file(part).hexdigest
486
+ raise "#{File.basename(dest)} checksum mismatch (got #{digest[0, 12]}…)" unless digest == sha256
487
+
488
+ File.rename(part, dest)
489
+ dest
490
+ ensure
491
+ FileUtils.rm_f("#{dest}.part")
492
+ end
493
+
494
+ # Every declared icons.android name must exist in the font that ships, or
495
+ # the tab renders blank on device with nothing in the log to explain it.
496
+ # Checked against the .codepoints file *shipped beside the .ttf*: 282
497
+ # names exist in both fonts at different codepoints, so the pair has to
498
+ # stay together.
499
+ def validate_icon_names!(font)
500
+ names = codepoint_names(font["codepoints"])
501
+ # Only the variable Symbols fonts carry an fvar/FILL axis; the classic
502
+ # set has no fvar at all, so `favorite.fill` there can only ever render
503
+ # the outline — better to say so than to ship a lie.
504
+ fillable = @config.native_android_icon_font_fetched?
505
+
506
+ problems = @config.tabs_for("android").filter_map do |tab|
507
+ icon = tab["icon"].to_s
508
+ base = icon.sub(/\.fill\z/, "")
509
+ if base != icon && !fillable
510
+ "tab #{tab["title"].inspect}: #{icon.inspect} — icon_font: #{@config.native_android_icon_font} " \
511
+ "has no FILL axis; use #{base.inspect}, or switch to icon_font: symbols"
512
+ elsif !names.include?(base)
513
+ "tab #{tab["title"].inspect}: #{icon.inspect} isn't an icon in #{font["file"]}"
514
+ end
515
+ end
516
+ return if problems.empty?
517
+
518
+ UI.die!("icons.android in config/everywhere.yml (#{names.size} icons in " \
519
+ "#{font["file"]} — browse them at https://fonts.google.com/icons):\n#{problems.join("\n")}")
520
+ end
521
+
522
+ # "name<space>hex" per line. Parsed in Ruby rather than shelled out to awk
523
+ # (macOS awk has no strtonum) and stored as a name set: the codepoint
524
+ # itself is the shell's business, ours is only "does this name exist".
525
+ def codepoint_names(path)
526
+ return Set.new unless File.file?(path)
527
+
528
+ File.readlines(path).each_with_object(Set.new) do |line, set|
529
+ name = line.split(/\s+/).first
530
+ set << name unless name.nil? || name.empty?
531
+ end
532
+ end
533
+
534
+ # Exactly one font ships. The dir is cleared first because the template
535
+ # carries the bundled classic set as its default — an app on `symbols`
536
+ # would otherwise pay 357 KB for a font it never reads, and an app on
537
+ # `none` would ship one it explicitly declined.
538
+ def write_icon_font(work)
539
+ dest = File.join(work, "app", "src", "main", "assets", "fonts")
540
+ FileUtils.mkdir_p(dest)
541
+ Dir.glob(File.join(dest, "*.{ttf,otf,codepoints}")).each { |stale| FileUtils.rm_f(stale) }
542
+ return unless @icon_font
543
+
544
+ FileUtils.cp(@icon_font["ttf"], File.join(dest, @icon_font["file"]))
545
+ FileUtils.cp(@icon_font["codepoints"],
546
+ File.join(dest, @config.native_android_icon_font_codepoints))
547
+ end
548
+
549
+ # --- manifest -------------------------------------------------------------
550
+
551
+ # Deep links, the auth callback scheme and runtime permissions go into a
552
+ # separate `stamped` source set that the manifest merger folds in, so the
553
+ # main manifest stays frozen and the default build stays clean — the
554
+ # analogue of the optional iOS App.entitlements. Unlike that file this one
555
+ # is always written: the frozen build script declares the source set, and
556
+ # a declared manifest that isn't there fails the merge.
557
+ def write_manifest(work)
558
+ errors = @config.permission_errors("android") + @config.auth_errors
559
+ UI.die!("config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
560
+
561
+ body = [permission_elements, activity_element].compact.join("\n\n")
562
+ path = File.join(work, "app", "src", "stamped", "AndroidManifest.xml")
563
+ FileUtils.mkdir_p(File.dirname(path))
564
+ File.write(path, <<~XML)
565
+ <?xml version="1.0" encoding="utf-8"?>
566
+ <!-- Written by `every build` for target #{@target} from config/everywhere.yml.
567
+ Do not edit; this source set is merged into app/src/main/AndroidManifest.xml.
568
+ Note for whoever edits this header next: XML forbids a double hyphen
569
+ anywhere inside a comment, so the flag spelling of the target must not
570
+ appear here. aapt2 does not warn — the manifest merger just fails to
571
+ parse the file, minutes into a Gradle run. -->
572
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
573
+ #{body.empty? ? " <!-- nothing declared: no permissions, deep links or sign-in scheme -->" : body}
574
+ </manifest>
575
+ XML
576
+ end
577
+
578
+ def permission_elements
579
+ entries = @config.android_manifest_permissions
580
+ return nil if entries.empty?
581
+
582
+ entries.map { |entry| " <uses-permission android:name=\"#{entry}\" />" }.join("\n")
583
+ end
584
+
585
+ # Both filter kinds hang off MainActivity: it's the activity the launcher
586
+ # starts and the one the shell routes URLs through, so a link and a
587
+ # sign-in callback arrive at the same place a cold start would.
588
+ def activity_element
589
+ filters = [deep_link_filters, auth_filter].compact
590
+ return nil if filters.empty?
591
+
592
+ [" <application>",
593
+ " <activity android:name=\"#{MAIN_ACTIVITY}\">",
594
+ filters.join("\n"),
595
+ " </activity>",
596
+ " </application>"].join("\n")
597
+ end
598
+
599
+ # autoVerify makes Android fetch /.well-known/assetlinks.json (which the
600
+ # gem already serves) and open matching links straight in the app, with no
601
+ # chooser. Verification failing is not fatal — the links simply keep
602
+ # opening in the browser — so the filter is stamped whenever deep linking
603
+ # is configured at all, and the missing Android half is a warning.
604
+ def deep_link_filters
605
+ domains = @config.deep_linking_domains
606
+ return nil unless @config.deep_linking? && domains.any?
607
+
608
+ UI.step("stamping deep links #{UI.dim("(#{domains.join(", ")})")}")
609
+ unless @config.deep_linking_android?
610
+ UI.warn "deep links won't auto-verify until deep_linking.android.package and " \
611
+ ".sha256_cert_fingerprints are set — until then they open in the browser"
612
+ end
613
+
614
+ domains.map do |host|
615
+ [" <intent-filter android:autoVerify=\"true\">",
616
+ " <action android:name=\"android.intent.action.VIEW\" />",
617
+ " <category android:name=\"android.intent.category.DEFAULT\" />",
618
+ " <category android:name=\"android.intent.category.BROWSABLE\" />",
619
+ " <data android:scheme=\"https\" android:host=\"#{xml_attr(host)}\"#{path_attribute} />",
620
+ " </intent-filter>"].join("\n")
621
+ end.join("\n")
622
+ end
623
+
624
+ # deep_linking.paths is written in Apple's component syntax; Android wants
625
+ # one of path / pathPrefix / pathPattern. Only the two shapes that
626
+ # translate exactly are translated — "/*" (and the default "*") mean every
627
+ # path, which on Android is simply no path attribute at all.
628
+ def path_attribute
629
+ paths = @config.deep_linking_paths
630
+ return "" if paths.length != 1 || ["*", "/*"].include?(paths.first)
631
+
632
+ path = xml_attr(paths.first)
633
+ path.end_with?("*") ? " android:pathPrefix=\"#{path.chomp("*")}\"" : " android:path=\"#{path}\""
634
+ end
635
+
636
+ # Hosts and paths are app-authored strings landing in XML attributes;
637
+ # everything else stamped into the manifest comes from a fixed map or a
638
+ # validated pattern. A quote here would rewrite the element, so escape the
639
+ # five characters that can.
640
+ def xml_attr(value)
641
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
642
+ .gsub('"', "&quot;").gsub("'", "&apos;")
643
+ end
644
+
645
+ # The custom scheme third-party sign-in returns through (everywhere.yml
646
+ # auth:). The Custom Tab hands the callback back by Intent, so unlike iOS
647
+ # — where ASWebAuthenticationSession claims the callback itself — this
648
+ # registration is what actually makes the return leg work.
649
+ def auth_filter
650
+ return nil unless @config.oauth?
651
+
652
+ scheme = @config.auth_scheme
653
+ UI.step("stamping sign-in callback scheme #{UI.dim("(#{scheme}://)")}")
654
+ [" <intent-filter>",
655
+ " <action android:name=\"android.intent.action.VIEW\" />",
656
+ " <category android:name=\"android.intent.category.DEFAULT\" />",
657
+ " <category android:name=\"android.intent.category.BROWSABLE\" />",
658
+ " <data android:scheme=\"#{scheme}\" />",
659
+ " </intent-filter>"].join("\n")
660
+ end
661
+
662
+ # --- native extensions ----------------------------------------------------
663
+
664
+ # The app repo's native/android/*.kt copied into the template's
665
+ # extensions/ package, plus a generated registry naming what
666
+ # everywhere.yml declares (native.android). The package is a plain source
667
+ # directory in the frozen module, so dropping files in IS the integration
668
+ # — the stamp-only contract holds.
669
+ def write_extensions(work)
670
+ dest = File.join(work, "app", "src", "main", "java", *SHELL_PACKAGE.split("."), "extensions")
671
+ # Regenerated wholesale: a .kt the app deleted must stop compiling in.
672
+ FileUtils.rm_rf(dest)
673
+ FileUtils.mkdir_p(dest)
674
+
675
+ source = File.join(@root, "native", "android")
676
+ sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.kt")).sort : []
677
+ sources.each do |file|
678
+ relative = file.delete_prefix("#{source}/")
679
+ if File.basename(file) == "EverywhereExtensions.kt"
680
+ UI.die!("native/android/#{relative}: EverywhereExtensions.kt is generated by " \
681
+ "`every build` — declare your types under native.android in config/everywhere.yml instead")
682
+ end
683
+ check_package!(file, relative)
684
+ target_file = File.join(dest, relative)
685
+ FileUtils.mkdir_p(File.dirname(target_file))
686
+ FileUtils.cp(file, target_file)
687
+ end
688
+
689
+ if @config.native_android?
690
+ UI.step("bundling native extensions #{UI.dim("(#{sources.length} Kotlin files from native/android)")}")
691
+ elsif sources.any?
692
+ UI.warn "native/android has Kotlin files but everywhere.yml declares nothing under " \
693
+ "native.android — they compile in, but no components/screens/splash are hooked up"
694
+ end
695
+ File.write(File.join(dest, "EverywhereExtensions.kt"), extensions_registry)
696
+ end
697
+
698
+ # The generated registry names app types unqualified, so they have to
699
+ # compile into the same package it does. Kotlin doesn't tie a package to a
700
+ # directory, so the copy alone can't establish it — and a file with no
701
+ # `package` line lands in the default package, which nothing outside it can
702
+ # import at all. Both mistakes surface from Kotlin as "unresolved
703
+ # reference: AboutFragment" pointing at generated code the author never
704
+ # wrote, so they're caught here instead, naming the line to add.
705
+ #
706
+ # No Swift equivalent: Swift has no package declaration, which is why the
707
+ # iOS side of this seam needs nothing.
708
+ def check_package!(file, relative)
709
+ declared = File.foreach(file).lazy
710
+ .filter_map { |line| line[/\A\s*package\s+([\w.]+)/, 1] }
711
+ .first
712
+ return if declared == EXTENSIONS_PACKAGE
713
+
714
+ found = declared ? "declares package #{declared}" : "declares no package"
715
+ UI.die!("native/android/#{relative} #{found} — every Kotlin file here compiles into the " \
716
+ "shell's extension package, so it has to start with:\n\n package #{EXTENSIONS_PACKAGE}\n")
717
+ end
718
+
719
+ # The generated half of the extension seam — member for member the shape
720
+ # the template ships as its empty default, because EverywhereApplication
721
+ # and MainActivity read these three names directly. Declared types are
722
+ # validated as Kotlin identifiers in preflight, so interpolation is safe;
723
+ # a declared-but-missing type fails the build with a Kotlin error naming
724
+ # it — the honest signal, exactly as on iOS.
725
+ def extensions_registry
726
+ components = @config.native_android_components
727
+ # A screen's identifier lives in the Fragment's own
728
+ # @HotwireDestinationDeepLink annotation ("hotwire://fragment/<id>"),
729
+ # so Hotwire only needs the classes; everywhere.yml keeps the ids to
730
+ # document and validate what the path rules may point at.
731
+ screens = @config.native_android_screens.values.uniq
732
+ splash = @config.native_android_splash
733
+
734
+ <<~KOTLIN
735
+ // Written by `every build` from config/everywhere.yml (native.android) — do
736
+ // not edit; changes belong in native/android/ and everywhere.yml.
737
+ //
738
+ // A declared component is registered under the name its own class states:
739
+ //
740
+ // class ChartComponent(name: String, delegate: BridgeDelegate<HotwireDestination>) :
741
+ // BridgeComponent<HotwireDestination>(name, delegate) {
742
+ // companion object { const val NAME = "chart" } // matches the JS side
743
+ // }
744
+ package #{EXTENSIONS_PACKAGE}
745
+
746
+ import android.content.Context
747
+ import android.view.View
748
+ import androidx.fragment.app.Fragment
749
+ import dev.hotwire.core.bridge.BridgeComponent
750
+ import dev.hotwire.core.bridge.BridgeComponentFactory
751
+ import dev.hotwire.navigation.destinations.HotwireDestination
752
+ import kotlin.reflect.KClass
753
+
754
+ object EverywhereExtensions {
755
+ val bridgeComponents: List<BridgeComponentFactory<HotwireDestination, BridgeComponent<HotwireDestination>>> =
756
+ #{kotlin_list(components.map { |type| "BridgeComponentFactory(#{type}.NAME, ::#{type})" })}
757
+
758
+ val fragmentDestinations: List<KClass<out Fragment>> =
759
+ #{kotlin_list(screens.map { |type| "#{type}::class" })}
760
+
761
+ fun splashView(context: Context): View? = #{splash ? "#{splash}(context)" : "null"}
762
+ }
763
+ KOTLIN
764
+ end
765
+
766
+ # "emptyList()" or a listOf() with one element per line, indented to sit
767
+ # under the property it initializes.
768
+ def kotlin_list(elements)
769
+ return "emptyList()" if elements.empty?
770
+
771
+ "listOf(\n#{elements.map { |element| " #{element}" }.join(",\n")}\n )"
772
+ end
773
+
774
+ # Third-party Maven coordinates → a generated script the frozen
775
+ # app/build.gradle.kts applies. Written as `add("implementation", …)`
776
+ # rather than `implementation(…)`: the Kotlin DSL's typed accessors are
777
+ # generated for the build script itself, not for a script pulled in with
778
+ # apply(from = …), where only the DependencyHandler API exists.
779
+ # Coordinates are validated by Config#native_android_package_errors, so
780
+ # nothing here can escape its string literal.
781
+ def write_native_packages(work)
782
+ packages = @config.native_android_packages
783
+ lines = packages.map { |coordinate| " add(\"implementation\", \"#{coordinate}\")" }
784
+ File.write(File.join(work, "app", "native-packages.gradle.kts"), <<~KOTLIN)
785
+ // Written by `every build` from config/everywhere.yml (native.android.packages)
786
+ // — do not edit; changes belong in config/everywhere.yml.
787
+ dependencies {
788
+ #{lines.join("\n")}
789
+ }
790
+ KOTLIN
791
+ return if packages.empty?
792
+
793
+ UI.step("pinning native packages #{UI.dim("(#{packages.length} Maven " \
794
+ "dependenc#{packages.length == 1 ? "y" : "ies"})")}")
795
+ end
796
+
797
+ # --- gradle ---------------------------------------------------------------
798
+
799
+ def gradle!(work, variant, dist_dir)
800
+ UI.step("building Android shell #{UI.dim("(Gradle, #{variant}, first run downloads " \
801
+ "Gradle and the Android Gradle Plugin)")}")
802
+ FileUtils.mkdir_p(dist_dir)
803
+ log = File.join(dist_dir, "android-build.log")
804
+ task = "#{GRADLE_MODULE}:assemble#{variant.capitalize}"
805
+ # -p instead of a chdir: the wrapper finds its own jar from $0, so an
806
+ # absolute gradlew with an explicit project dir works from anywhere
807
+ # (Shellout.run_logged! has no chdir). --console=plain keeps the stream
808
+ # line-oriented for LogFilter whether or not a TTY is attached.
809
+ Shellout.run_logged!(gradle_env,
810
+ [File.join(work, "gradlew"), "-p", work, "--console=plain", task],
811
+ log: log, filter: LogFilter.for(:gradle))
812
+ end
813
+
814
+ # The whole toolchain is handed to Gradle explicitly. ANDROID_HOME is what
815
+ # AGP reads when there's no local.properties — and we never write one, so
816
+ # the stamped project stays clean and Android Studio can resolve its own
817
+ # SDK. GRADLE_USER_HOME redirects the 1.4 GB of Gradle distribution, AGP
818
+ # and Hotwire artifacts out of the gem and into a cache shared by every app.
819
+ def gradle_env
820
+ { "JAVA_HOME" => @java_home,
821
+ "ANDROID_HOME" => @sdk_root,
822
+ "ANDROID_SDK_ROOT" => @sdk_root,
823
+ "GRADLE_USER_HOME" => Paths.android_gradle_home }
824
+ end
825
+
826
+ # Copy the APK out of the module's build dir. Release is unsigned until
827
+ # keystore signing lands (docs/android-plan.md §9), and AGP spells that
828
+ # variant app-release-unsigned.apk — accept either name so the collect
829
+ # step doesn't have to be revisited when signing arrives.
830
+ def collect(work, variant, dist_dir)
831
+ dir = File.join(work, "app", "build", "outputs", "apk", variant)
832
+ built = ["app-#{variant}.apk", "app-#{variant}-unsigned.apk"]
833
+ .map { |file| File.join(dir, file) }
834
+ .find { |path| File.file?(path) }
835
+ UI.die!("Gradle reported success but no APK in #{UI.short_path(dir)}") unless built
836
+
837
+ dest = File.join(dist_dir, "#{name}.apk")
838
+ FileUtils.rm_f(dest)
839
+ FileUtils.cp(built, dest)
840
+ dest
841
+ end
842
+ end
843
+ end
844
+ end