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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/everywhere/bridge.js +60 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- 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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
642
|
+
.gsub('"', """).gsub("'", "'")
|
|
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
|