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
@@ -30,6 +30,7 @@ module Everywhere
30
30
  when :notarize then notarize(line)
31
31
  when :tebako then tebako(line)
32
32
  when :xcodebuild then xcodebuild(line)
33
+ when :gradle then gradle(line)
33
34
  end
34
35
  end
35
36
 
@@ -153,6 +154,79 @@ module Everywhere
153
154
  end
154
155
  end
155
156
 
157
+ # --- gradle (Android shell) -----------------------------------------------
158
+ #
159
+ # Gradle is the quiet one: a cold build is ~120 lines, ~83 of them
160
+ # "> Task :app:…". So this filter isn't fighting volume like the two above —
161
+ # it exists because of two specific noises that make a healthy build look
162
+ # broken. Gradle 9 ends every build with a four-line "incompatible with
163
+ # Gradle 10" deprecation banner earned by the Android Gradle Plugin, not by
164
+ # anything the user wrote; and hotwire-native-android 1.3.0 compiled with
165
+ # Kotlin 2.3.0 emits exactly 7 `w:` warnings from navigation-fragments
166
+ # (deprecated overrides, getParcelableArrayList, KT-73255) that no app can
167
+ # act on. Both are suppressed here and both stay in the on-disk log.
168
+ #
169
+ # The line that decides a warning's fate is its path: app-authored Kotlin is
170
+ # stamped into extensions/, and that's the only code the user can fix.
171
+ MILESTONES = {
172
+ /:process\w*Manifest\b/ => "merging manifests",
173
+ /:merge\w*Resources\b/ => "merging resources",
174
+ /:merge\w*Assets\b/ => "bundling assets",
175
+ /:compile\w*Kotlin\b/ => "compiling Kotlin",
176
+ /:compile\w*JavaWithJavac\b/ => "compiling Java",
177
+ /:package\w+\b/ => "packaging APK"
178
+ }.freeze
179
+
180
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
181
+ def gradle(line)
182
+ short = UI.short_path(line)
183
+ case line
184
+ # Kotlin/javac/AAPT failures, and Gradle's own failure block.
185
+ when /\Ae: /, /\berror:/i, /\AFAILURE: /, /\A\* What went wrong:/, /\ABUILD FAILED/
186
+ " #{UI.red(short)}"
187
+ when /\Aw: /
188
+ return " #{UI.yellow(short)}" if line.include?("/extensions/")
189
+
190
+ once(:shell_warning) { UI.note_line("Kotlin warnings from the shell template — see the full log") }
191
+ when /\ABUILD SUCCESSFUL(?: in (.+))?/
192
+ UI.note_line("build succeeded#{Regexp.last_match(1) && " (#{Regexp.last_match(1)})"}",
193
+ marker: "✓", color: :green)
194
+ when /\A\d+ actionable tasks?:/
195
+ UI.note_line(line)
196
+
197
+ # First-run downloads are the whole reason a cold build takes minutes.
198
+ when /\ADownloading https:\/\/services\.gradle\.org/
199
+ once(:gradle_dist) { UI.note_line("downloading Gradle (first run only)") }
200
+ when /\AStarting a Gradle Daemon/
201
+ once(:daemon) { UI.note_line("starting the Gradle daemon") }
202
+
203
+ # ~83 task lines. Milestones once each; the rest, including everything
204
+ # already up to date, is background.
205
+ when /\A> Task (\S+)/
206
+ task = Regexp.last_match(1)
207
+ return :drop if line.match?(/(UP-TO-DATE|FROM-CACHE|NO-SOURCE|SKIPPED)\s*\z/)
208
+
209
+ label = MILESTONES.find { |pattern, _| task.match?(pattern) }&.last or return :drop
210
+
211
+ once("task-#{label}") { UI.note_line(label) }
212
+
213
+ # Gradle 9's deprecation banner and its follow-on lines, the incubating
214
+ # problems report, the daemon's welcome, and any progress redraw that
215
+ # survives --console=plain.
216
+ when /Deprecated Gradle features were used in this build/,
217
+ /--warning-mode all/,
218
+ /\AFor more information[, ].*docs\.gradle\.org/,
219
+ /\A\[Incubating\] Problems report/,
220
+ /\AWelcome to Gradle /,
221
+ /\A<[=\-]+>/,
222
+ /\A> (Configure|Starting|Loading|Calculating|IDLE)/
223
+ :drop
224
+ else
225
+ nil # unrecognized -> caller shows it dimmed
226
+ end
227
+ end
228
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
229
+
156
230
  # Show a line only the first time its key is seen; :drop the repeats. Keeps a
157
231
  # long notarization from scrolling a wall of identical "waiting" lines (and
158
232
  # notarytool's thrice-echoed submission id from showing three times).
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "erb"
4
4
  require "json"
5
+ require_relative "config"
5
6
 
6
7
  module Everywhere
7
8
  # Server-side view helpers mirroring the JS bridge's platform detection, so
@@ -25,11 +26,7 @@ module Everywhere
25
26
  # :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
26
27
  # marker, so it's correct even before the JS bridge has booted.
27
28
  def native_platform
28
- ua = _everywhere_user_agent or return nil
29
- return :ios if ua.include?("Hotwire Native iOS")
30
- return :android if ua.include?("Hotwire Native Android")
31
-
32
- nil
29
+ Everywhere.native_platform_of(_everywhere_user_agent)
33
30
  end
34
31
 
35
32
  # The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_handoff"
4
+
5
+ module Everywhere
6
+ # OmniAuth 2 and the native sign-in flow, introduced to each other.
7
+ #
8
+ # Since CVE-2015-9284, OmniAuth only starts a provider flow from a POST
9
+ # carrying a CSRF token (omniauth-rails_csrf_protection). Right for browsers —
10
+ # and impossible for the native flow, which enters the provider path through
11
+ # /everywhere/auth/start's redirect: a GET, in a browser session that has no
12
+ # CSRF token to give. Without help, tapping "Sign in with GitHub" in the app
13
+ # ends on OmniAuth's failure page.
14
+ #
15
+ # # config/initializers/omniauth.rb, after your provider setup
16
+ # require "everywhere/omniauth"
17
+ # Everywhere::OmniAuth.protect!
18
+ #
19
+ # Browsers keep exactly the validation they had — whatever
20
+ # request_validation_phase was set to keeps running. The one extra shape
21
+ # allowed through is the native flow's entry: a top-level, not-cross-site GET
22
+ # carrying a marker cookie that /everywhere/auth/start sealed seconds ago.
23
+ # Nobody else can mint that marker (AES-GCM under the app's secret), start
24
+ # refuses cross-site initiators outright, and the freshness window means a
25
+ # marker lingering in Safari's jar after a real sign-in stops vouching for
26
+ # anything almost immediately.
27
+ module OmniAuth
28
+ # How long after start seals the marker its GET still reads as the native
29
+ # flow arriving. The real gap is one redirect — milliseconds — but the
30
+ # sheet can sit on our page briefly on a slow network; anything past this
31
+ # is a browser wandering in.
32
+ ENTRY_WINDOW = 90
33
+
34
+ class << self
35
+ # Wire OmniAuth's global config: allow GET request phases (they're
36
+ # refused per-request by the validator unless they prove themselves),
37
+ # and wrap the configured validation phase so everything that isn't the
38
+ # native entry is judged exactly as before. Safe to call more than once —
39
+ # re-running replaces the wrapper, never stacks it.
40
+ def protect!(entry_window: ENTRY_WINDOW)
41
+ unless defined?(::OmniAuth) && ::OmniAuth.respond_to?(:config)
42
+ raise "Everywhere::OmniAuth.protect! needs OmniAuth loaded first — " \
43
+ "call it below your OmniAuth/Devise setup (config/initializers/omniauth.rb)"
44
+ end
45
+
46
+ config = ::OmniAuth.config
47
+ config.allowed_request_methods = Array(config.allowed_request_methods) | [:get]
48
+ config.silence_get_warning = true if config.respond_to?(:silence_get_warning=)
49
+
50
+ fallback = config.request_validation_phase
51
+ fallback = fallback.fallback if fallback.is_a?(RequestValidator)
52
+ config.request_validation_phase = RequestValidator.new(fallback: fallback, entry_window: entry_window)
53
+ end
54
+ end
55
+
56
+ # OmniAuth request_validation_phase: passes the native flow's entry,
57
+ # delegates everything else. A nil fallback means the app had no request
58
+ # validation configured, and it still has none — this never tightens.
59
+ class RequestValidator
60
+ attr_reader :fallback
61
+
62
+ def initialize(fallback: nil, entry_window: ENTRY_WINDOW)
63
+ @fallback = fallback
64
+ @entry_window = entry_window
65
+ end
66
+
67
+ def call(env)
68
+ return if native_entry?(env)
69
+
70
+ @fallback&.call(env)
71
+ end
72
+
73
+ private
74
+
75
+ # The one shape the native flow produces: a GET, not from a cross-site
76
+ # initiator, carrying a marker minted moments ago. POSTs never qualify —
77
+ # they come from rendered pages that have a CSRF token to present.
78
+ def native_entry?(env)
79
+ return false unless env["REQUEST_METHOD"] == "GET"
80
+ return false if env["HTTP_SEC_FETCH_SITE"] == "cross-site"
81
+
82
+ marker = AuthHandoff.marker(env)
83
+ issued = marker && marker["iat"]
84
+ !!issued && Time.now.to_i - issued.to_i <= @entry_window
85
+ end
86
+ end
87
+ end
88
+ end
@@ -54,6 +54,30 @@ module Everywhere
54
54
  "reinstall ruby_everywhere or pass --template-dir")
55
55
  end
56
56
 
57
+ # The Hotwire Native Android shell template bundled inside the gem:
58
+ # support/mobile/android/ holds a frozen Gradle project (wrapper committed)
59
+ # the CLI stamps per-app — everywhere.properties + assets + res, never a
60
+ # build script. Same freeze contract the iOS template has.
61
+ def bundled_android_dir
62
+ File.join(gem_root, "support", "mobile", "android")
63
+ end
64
+
65
+ # Absolute path to the Android template, or nil when it isn't there. Unlike
66
+ # #ios_dir, nil is the NORMAL state until the template lands — callers must
67
+ # read it as "Android isn't buildable in this release yet", not as a corrupt
68
+ # install. settings.gradle.kts is the marker because it's the one file every
69
+ # Gradle project must have at its root.
70
+ def android_dir
71
+ bundled_android_dir if File.exist?(File.join(bundled_android_dir, "settings.gradle.kts"))
72
+ end
73
+
74
+ # Like #android_dir but aborts with a helpful message when the template is
75
+ # missing. The --template-dir hint covers dev on a modified template.
76
+ def android_dir!
77
+ android_dir or UI.die!("couldn't find the bundled Android template at #{bundled_android_dir}; " \
78
+ "reinstall ruby_everywhere or pass --template-dir")
79
+ end
80
+
57
81
  # Per-user cache dir. Keeps heavy build state out of the gem and the app.
58
82
  def cache_dir
59
83
  File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
@@ -79,6 +103,29 @@ module Everywhere
79
103
  File.join(cache_dir, "ios-packages")
80
104
  end
81
105
 
106
+ # The stamped copy of the Android template for one app, keyed by
107
+ # applicationId. Persistent for the same reasons ios_work_dir is: Gradle's
108
+ # incremental state is path-sensitive, and the stamped project opens in
109
+ # Android Studio unmodified.
110
+ def android_work_dir(application_id)
111
+ File.join(cache_dir, "android", application_id)
112
+ end
113
+
114
+ # GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
115
+ # distribution, the Android Gradle Plugin and the Hotwire artifacts are
116
+ # downloaded once and stay warm across every app — and never land inside the
117
+ # installed gem. Same trick as cargo_target_dir.
118
+ def android_gradle_home
119
+ File.join(cache_dir, "android-gradle")
120
+ end
121
+
122
+ # Where the Material Symbols icon font is cached after its one-time fetch.
123
+ # The font is ~10 MB, so it's fetched on first Android build instead of
124
+ # riding along in a 229 KB gem that most users never build Android with.
125
+ def android_fonts_dir
126
+ File.join(cache_dir, "android-fonts")
127
+ end
128
+
82
129
  # Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
83
130
  # here so cargo never dumps a multi-GB target/ into the installed gem, and so
84
131
  # the shell compiles warm across projects — the shell source is identical for
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
 
6
6
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
7
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -0,0 +1,217 @@
1
+ # RubyEverywhere Android Shell Template
2
+
3
+ A canonical [Hotwire Native Android][1] shell that wraps a deployed Ruby web app
4
+ (remote URL). The RubyEverywhere CLI stamps a copy of this directory per app —
5
+ the template itself is **never edited per-app**.
6
+
7
+ The iOS template is the reference implementation; this one is a feature-for-feature
8
+ port of `support/mobile/ios/`, and where the two differ the difference is forced
9
+ by the platform and is explained in a comment at the site.
10
+
11
+ ## Freeze rules
12
+
13
+ Every build script, layout, theme and Kotlin source here is written once and
14
+ frozen. When `every build --target android-*` stamps a copy, it overwrites
15
+ **exactly** these files and nothing else:
16
+
17
+ 1. `app/everywhere.properties` — applicationId, versionName, versionCode, app
18
+ label. The direct analogue of iOS's `App.xcconfig`: identity is stamped
19
+ without ever touching a build script. `app/build.gradle.kts` reads it at
20
+ configuration time.
21
+ 2. `app/src/main/assets/everywhere.json` — runtime config (remote URL, entry
22
+ path, colours, permissions, auth, universal-link hosts);
23
+ `Config#to_shell_json`.
24
+ 3. `app/src/main/assets/json/path-configuration.json` — baked rules +
25
+ `settings.tabs` (`Config#path_configuration_json("android")`). The same
26
+ document is served live at `/everywhere/android_v1.json` by the gem's
27
+ `MobileConfigEndpoint`, which the shell loads as its second, auth-aware
28
+ source.
29
+ 4. `app/src/main/assets/fonts/` — the selected Material icon font and its
30
+ `.codepoints` map. The bundled classic set
31
+ (`MaterialIcons-Regular.ttf`, 357 KB, 2,235 icons) is the offline fallback;
32
+ `icon_font: symbols` fetches the 10.6 MB variable font into
33
+ `~/.rubyeverywhere/android-fonts/` on first use. The two files must always
34
+ travel together — 282 names exist in both fonts with *different* codepoints.
35
+ 5. `app/src/main/res/values/colors.xml` and `values-night/colors.xml` — the
36
+ light and dark halves of `appearance.tint_color` / `background_color`.
37
+ Everything in the theme derives from those two colours.
38
+ 6. `app/src/main/res/values/ic_launcher_background.xml` and
39
+ `res/drawable-<dpi>/ic_launcher_foreground.png` — the adaptive launcher
40
+ icon's two layers. `minSdk 28` means there are no legacy
41
+ `mipmap-<dpi>/ic_launcher.png` fallbacks to generate.
42
+ 7. *(optionally)* `app/src/stamped/AndroidManifest.xml` — deep-link intent
43
+ filters, the auth callback scheme, and runtime `<uses-permission>` entries.
44
+ Written **only** when `everywhere.yml` declares `deep_linking:`, `auth:` or
45
+ `permissions:`. It is a separate manifest, folded in by the merger, so the
46
+ main manifest stays frozen — the analogue of iOS's optional
47
+ `App.entitlements`. See the note below on how it is wired.
48
+ 8. *(optionally)* `app/native-packages.gradle.kts` — Maven coordinates from
49
+ `native.android.packages`, and
50
+ `app/src/main/java/com/rubyeverywhere/shell/extensions/` — the app repo's
51
+ `native/android/*.kt` plus the generated `EverywhereExtensions.kt` registry.
52
+ Both ship as empty defaults so the raw template builds unchanged.
53
+ 9. `app/src/stamped/res/drawable*/` — the app's own images, generated from
54
+ `native/android/assets/**` by `Everywhere::AndroidResources`, reachable from
55
+ extension code as `R.drawable.<name>`. Registered as a second `res` srcDir on
56
+ `main` (see `sourceSets` in `app/build.gradle.kts`) and **cleared on every
57
+ build**, which is why it is its own directory rather than more files in
58
+ `main/res`: stamping merges over the work dir instead of wiping it, so an
59
+ asset the app deleted has to be removed by the generator that wrote it.
60
+ Filenames are the whole API and match iOS's exactly — `logo@2x~dark.png`
61
+ becomes `drawable-night-xhdpi/logo.png`.
62
+ 10. *(dev only)* `app/src/debug/assets/dev-url.txt` — the dev-server URL.
63
+ Android has no `SIMCTL_CHILD_*`, so `every dev --android` stamps this and
64
+ rebuilds, which it has to do anyway. Read only in debug builds; the debug
65
+ source set never ships in a release.
66
+
67
+ **Structural changes** — new source files, new dependencies, a plugin bump, a
68
+ new layout — mean deliberately editing this template and recommitting, never
69
+ editing a stamped copy.
70
+
71
+ ### Why `src/stamped/` is wired the way it is
72
+
73
+ AGP merges one manifest per *source set*, and without product flavours a variant
74
+ has exactly two: `main` and its build type. The generated manifest is therefore
75
+ registered as **both** build types' manifest in `app/build.gradle.kts`:
76
+
77
+ ```kotlin
78
+ sourceSets {
79
+ getByName("debug") { manifest.srcFile("src/stamped/AndroidManifest.xml") }
80
+ getByName("release") { manifest.srcFile("src/stamped/AndroidManifest.xml") }
81
+ }
82
+ ```
83
+
84
+ A product flavour would have been the textbook answer, but it renames every task
85
+ (`assembleStampedDebug`), and the builder, the dev loop and this README all
86
+ depend on plain `:app:assembleDebug`.
87
+
88
+ That leaves the build-type manifest slot occupied, so the debug-only cleartext
89
+ policy is expressed as a **resource overlay** instead:
90
+ `src/debug/res/xml/network_security_config.xml` overrides the release-safe
91
+ `src/main/res/xml/network_security_config.xml` of the same name. The main
92
+ manifest's `android:networkSecurityConfig` attribute never changes.
93
+
94
+ ## Dependency pin policy
95
+
96
+ The SPM equivalent. Plugin versions are pinned in the root `build.gradle.kts`,
97
+ library versions in `app/build.gradle.kts`, and both are **exact** — no ranges,
98
+ no `+`, no version catalog. Moving a pin is a deliberate edit here.
99
+
100
+ | | pinned at |
101
+ | --- | --- |
102
+ | Gradle | 9.2.0 (`-bin`, see below) |
103
+ | Android Gradle Plugin | 8.13.2 |
104
+ | Kotlin / kotlinx.serialization plugin | 2.3.0 |
105
+ | `dev.hotwire:core`, `dev.hotwire:navigation-fragments` | 1.3.0 |
106
+ | `compileSdk` / `targetSdk` | 35 |
107
+ | `minSdk` | 28 — Hotwire Native Android's floor, not negotiable |
108
+ | Java toolchain / JVM target | 17 |
109
+
110
+ The wrapper deliberately uses `gradle-9.2.0-**bin**.zip`, not `-all`: the `-all`
111
+ distribution unpacks to 522 MB in `GRADLE_USER_HOME` and the difference is
112
+ Gradle's own sources and javadoc, which only an IDE indexing the Gradle API ever
113
+ reads.
114
+
115
+ `settings.gradle.kts` keeps the `foojay-resolver-convention` plugin so the Java
116
+ toolchain can be provisioned independently of the launcher JVM — without it the
117
+ build fails on a machine whose only JDK is 21.
118
+
119
+ ## Building standalone
120
+
121
+ The raw template builds green with placeholder values, unsigned:
122
+
123
+ ```
124
+ export JAVA_HOME=$(/usr/libexec/java_home -v 17)
125
+ export ANDROID_HOME=$HOME/Library/Android/sdk
126
+ ./gradlew :app:assembleDebug
127
+ ```
128
+
129
+ Output: `app/build/outputs/apk/debug/app-debug.apk`.
130
+
131
+ The first build downloads roughly 1.4 GB (Gradle, the Android Gradle Plugin, the
132
+ Hotwire artifacts) and takes a couple of minutes, almost all of it network. Warm
133
+ no-op rebuilds are around 7 seconds. The CLI redirects `GRADLE_USER_HOME` to
134
+ `~/.rubyeverywhere/android-gradle` so that cache is shared across apps and never
135
+ lands inside the installed gem.
136
+
137
+ ## Dev loop
138
+
139
+ The shell is remote-mode only (`remote_url` in `everywhere.json`). For local
140
+ development, `every dev --android` stamps `src/debug/assets/dev-url.txt`,
141
+ rebuilds, reinstalls and relaunches — the Android equivalent of pressing `i` in
142
+ the iOS loop. The URL resolves down a three-step ladder:
143
+
144
+ 1. `adb reverse tcp:<port> tcp:<port>`, then `http://127.0.0.1:<port>` — correct
145
+ on emulators *and* physical devices. Preferred.
146
+ 2. `http://10.0.2.2:<port>` — the emulator's alias for the host loopback, used
147
+ automatically when `adb reverse` fails.
148
+ 3. An explicit `--dev-url` LAN address, for physical devices.
149
+
150
+ Cleartext to those hosts is allowed only in debug builds, and only for the hosts
151
+ themselves — never `cleartextTrafficPermitted` globally. See
152
+ `app/src/debug/res/xml/network_security_config.xml`.
153
+
154
+ ## Shape of the shell
155
+
156
+ | file | role |
157
+ | --- | --- |
158
+ | `EverywhereApplication.kt` | Hotwire config, bridge + destination + route-handler registration, first path-configuration load |
159
+ | `EverywhereConfig.kt` | `everywhere.json` decode; roots, dev override, instance override, auth, deep links |
160
+ | `EverywhereEvents.kt` | page → shell control messages, replacing iOS's NotificationCenter |
161
+ | `MainActivity.kt` | the SceneDelegate counterpart: tabs, splash, reset, badges, deep links, sign-in |
162
+ | `MainTabs.kt` | `settings.tabs` → the fixed pool of five NavigatorHosts |
163
+ | `IconFont.kt` | Material icons resolved by name from the bundled font |
164
+ | `WebFragment.kt`, `WebBottomSheetFragment.kt` | the web destinations |
165
+ | `bridge/` | the six `everywhere--*` bridge components and the control channel |
166
+ | `AuthFlow.kt` | third-party sign-in in a Custom Tab |
167
+ | `EverywhereHost.kt` | what app-authored `native/android/` code calls: `everywhereVisit`, `everywhereFetch` |
168
+ | `extensions/EverywhereExtensions.kt` | generated app-extension registry (empty by default) |
169
+
170
+ ### What app-authored screens get
171
+
172
+ `native/android/*.kt` compiles into `extensions/` and must declare
173
+ `package com.rubyeverywhere.shell.extensions` — the generated registry names
174
+ those types unqualified, and Kotlin ties nothing to the directory, so the package
175
+ line is what makes them referenceable. `every build` refuses a file without it.
176
+
177
+ The two helpers in `EverywhereHost.kt` are named to match iOS's
178
+ `EverywhereHost.swift` so extension code reads the same on both platforms:
179
+
180
+ * `everywhereVisit("/path")` — route through the active navigator, so a native
181
+ screen's links get the full path-configuration treatment (modals, other native
182
+ screens) like any web-driven visit.
183
+ * `everywhereFetch("/builds.json")` — GET from the app's own server carrying the
184
+ WebView's session cookie and the native User-Agent, so `native_app?` holds
185
+ server-side and an authenticated endpoint answers. Suspending; throws
186
+ `EverywhereFetchError` on a non-2xx.
187
+
188
+ Images live in `native/android/assets/**` and arrive as `R.drawable.<name>`
189
+ (freeze rule 9). There is no `everywhereHost` counterpart to iOS's — a Hotwire
190
+ Android destination is already a `Fragment`.
191
+
192
+ ### Three invariants that will break the app if they are violated
193
+
194
+ Measured on a device during the Phase 0 spike, not inferred:
195
+
196
+ 1. **`navigatorConfigurations()` must return a configuration for all five hosts,
197
+ on every launch.** `NavigatorHost.onCreate` throws
198
+ `IllegalStateException("No configuration found for NavigatorHost")` for any
199
+ host in `activity_main.xml` it can't find one for. "Not a tab right now" is
200
+ `isVisible = false`, **never** a shorter list — `switchTab` only hides hosts
201
+ present in the new list, so a dropped host stays on screen.
202
+ 2. **`lazyLoadTabs = true` is mandatory.** With eager loading all five pooled
203
+ hosts cold-boot a WebView and fetch their start location at launch,
204
+ including the hidden auth-gated ones.
205
+ 3. **A second `load()` is cheap but lossy.** Fragments and WebViews survive
206
+ (~96 ms), but `loadMenu()` opens with `menu.clear()`, which destroys badges
207
+ and icons, and resets Material's `selectedItemId` to 0 — which then takes the
208
+ *reselect* path and clears the selected tab's backstack whenever the selected
209
+ tab is the first one. `MainActivity.applyTabs` is the only caller and does
210
+ all three fix-ups; nothing else may call `load()`.
211
+
212
+ Tab **path** changes are not covered by any of that: `NavigatorHost` caches its
213
+ configuration in `onCreate`, so a changed `startLocation` needs
214
+ `Activity.recreate()`. `MainTabs` keeps two fingerprints so the cheap path is
215
+ taken whenever it can be.
216
+
217
+ [1]: https://github.com/hotwired/hotwire-native-android
@@ -0,0 +1,156 @@
1
+ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2
+ import java.util.Properties
3
+
4
+ plugins {
5
+ id("com.android.application")
6
+ id("org.jetbrains.kotlin.android")
7
+ id("org.jetbrains.kotlin.plugin.serialization")
8
+ }
9
+
10
+ // Per-app identity, read from a properties file rather than written into this
11
+ // script. `every build` overwrites everywhere.properties and nothing else here
12
+ // — the same contract as iOS's App.xcconfig, for the same reason: a build
13
+ // script the CLI edits is a build script that drifts.
14
+ val everywhere = Properties().apply {
15
+ file("everywhere.properties").inputStream().use { load(it) }
16
+ }
17
+
18
+ fun stamped(key: String, default: String): String =
19
+ everywhere.getProperty("everywhere.$key")?.takeIf { it.isNotBlank() } ?: default
20
+
21
+ android {
22
+ // The namespace is the R class / BuildConfig package and is frozen. It is
23
+ // deliberately NOT the applicationId: apps rename their bundle id freely,
24
+ // and a namespace change would move every generated class.
25
+ namespace = "com.rubyeverywhere.shell"
26
+ compileSdk = 35
27
+
28
+ defaultConfig {
29
+ applicationId = stamped("applicationId", "com.rubyeverywhere.app")
30
+ versionName = stamped("versionName", "0.1.0")
31
+ versionCode = stamped("versionCode", "1").toInt()
32
+
33
+ // minSdk 28 is Hotwire Native Android's floor and is not negotiable.
34
+ // It also clears API 26 for Typeface.Builder.setFontVariationSettings,
35
+ // which IconFont needs for the Material Symbols FILL axis.
36
+ minSdk = 28
37
+ targetSdk = 35
38
+
39
+ // Consumed by AndroidManifest.xml. The label is a placeholder so the
40
+ // manifest itself stays frozen when the app is renamed.
41
+ manifestPlaceholders["everywhereLabel"] = stamped("label", "RubyEverywhere")
42
+ }
43
+
44
+ sourceSets {
45
+ // Deep-link intent filters, the auth callback scheme and runtime
46
+ // <uses-permission> entries are generated into their own manifest so
47
+ // the main one never changes. AGP merges one manifest per build type,
48
+ // and the generated file is identical for both — hence the same path
49
+ // twice. See src/stamped/AndroidManifest.xml.
50
+ getByName("debug") { manifest.srcFile("src/stamped/AndroidManifest.xml") }
51
+ getByName("release") { manifest.srcFile("src/stamped/AndroidManifest.xml") }
52
+
53
+ // The app's own images (native/android/assets/**) are generated into
54
+ // src/stamped/res as drawables. A second res dir on `main` rather than
55
+ // more files in main/res, because the generator owns the directory
56
+ // outright and clears it every build — a deleted asset has to stop
57
+ // shipping, and stamping merges over the work dir instead of wiping it.
58
+ //
59
+ // On `main`, deliberately, not on a build type: same-variant duplicates
60
+ // are a merge ERROR, so an asset that collides with a template drawable
61
+ // fails the build instead of silently overriding it. AGP ignores the
62
+ // directory when it doesn't exist, which is how the raw template builds.
63
+ getByName("main") { res.srcDir("src/stamped/res") }
64
+ }
65
+
66
+ buildTypes {
67
+ getByName("debug") {
68
+ isDebuggable = true
69
+ }
70
+ release {
71
+ isMinifyEnabled = false
72
+ proguardFiles(
73
+ getDefaultProguardFile("proguard-android-optimize.txt"),
74
+ "proguard-rules.pro"
75
+ )
76
+ }
77
+ }
78
+
79
+ buildFeatures {
80
+ // BuildConfig.DEBUG gates web-view debugging, verbose logging and the
81
+ // dev-server asset. Nothing else in the shell branches on build type.
82
+ buildConfig = true
83
+ }
84
+
85
+ compileOptions {
86
+ sourceCompatibility = JavaVersion.VERSION_17
87
+ targetCompatibility = JavaVersion.VERSION_17
88
+ }
89
+
90
+ lint {
91
+ // `lintVitalRelease` crashes on this exact toolchain (AGP 8.13.2 +
92
+ // Kotlin 2.3.0). Its build-script visitor builds light classes for
93
+ // every `.gradle.kts` in the project and dies inside the Kotlin
94
+ // compiler with "findFirCompiledSymbol only works on compiled
95
+ // declarations" the moment one script `apply(from:)`s another — which
96
+ // native-packages.gradle.kts is, by design.
97
+ //
98
+ // Verified by bisection: commenting out the `apply(from:)` below makes
99
+ // lintVitalAnalyzeRelease pass, and nothing else does. So this disables
100
+ // a check that cannot run, not one that was failing us. Revisit when
101
+ // either version moves.
102
+ checkReleaseBuilds = false
103
+ }
104
+
105
+ packaging {
106
+ // The icon font is already a compressed container; deflating it again
107
+ // costs build time and saves nothing.
108
+ resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
109
+ }
110
+ }
111
+
112
+ kotlin {
113
+ compilerOptions {
114
+ jvmTarget.set(JvmTarget.JVM_17)
115
+ }
116
+ }
117
+
118
+ java {
119
+ toolchain {
120
+ languageVersion = JavaLanguageVersion.of(17)
121
+ }
122
+ }
123
+
124
+ dependencies {
125
+ // Hotwire Native. Pinned exactly, not to a range: this is the template's
126
+ // dependency pin policy, the analogue of iOS's Package.resolved.
127
+ implementation("dev.hotwire:core:1.3.0")
128
+ implementation("dev.hotwire:navigation-fragments:1.3.0")
129
+
130
+ // Material — BottomNavigationView, badges, bottom sheets, theming.
131
+ implementation("com.google.android.material:material:1.12.0")
132
+
133
+ // activity_main.xml's root.
134
+ implementation("androidx.constraintlayout:constraintlayout:2.2.1")
135
+
136
+ // WebViewCompat: addWebMessageListener (the control channel) and
137
+ // addDocumentStartJavaScript (window.__EVERYWHERE_CONFIG__). Android
138
+ // WebView has no user-script API of its own; these are the substitutes.
139
+ implementation("androidx.webkit:webkit:1.12.1")
140
+
141
+ // Custom Tabs — the ASWebAuthenticationSession stand-in for third-party
142
+ // sign-in, and what Hotwire's own BrowserTabRouteDecisionHandler uses.
143
+ implementation("androidx.browser:browser:1.8.0")
144
+
145
+ // Biometrics + the encrypted store they gate.
146
+ implementation("androidx.biometric:biometric:1.1.0")
147
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
148
+
149
+ // everywhere.json, the bridge components' message payloads, and the
150
+ // control channel's postMessage bodies.
151
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
152
+ }
153
+
154
+ // GENERATED: Maven coordinates from `native.android.packages`. Applied last so
155
+ // an app's own dependency can override a version above if it must.
156
+ apply(from = "native-packages.gradle.kts")
@@ -0,0 +1,8 @@
1
+ # STAMPED by `every build --android`. The direct analogue of iOS's
2
+ # App.xcconfig: identity lives here so no build script is ever edited per app.
3
+ # The values below are the template's placeholders — the raw template builds
4
+ # green with them.
5
+ everywhere.applicationId=com.rubyeverywhere.app
6
+ everywhere.versionName=0.1.0
7
+ everywhere.versionCode=1
8
+ everywhere.label=RubyEverywhere
@@ -0,0 +1,16 @@
1
+ // GENERATED by `every build --android` from `native.android.packages` in
2
+ // config/everywhere.yml. The checked-in copy is the empty default, so the raw
3
+ // template builds unchanged.
4
+ //
5
+ // native:
6
+ // android:
7
+ // packages:
8
+ // - "com.airbnb.android:lottie:6.4.0"
9
+ //
10
+ // This is a script plugin applied from app/build.gradle.kts, so it has no
11
+ // typed `implementation(...)` accessor — dependencies are added by
12
+ // configuration name. Keeping the generated surface to a single file means
13
+ // the frozen build script never changes shape when an app adds a package.
14
+ dependencies {
15
+ // add("implementation", "group:artifact:version")
16
+ }