ruby_everywhere 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,409 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.content.res.Configuration
6
+ import android.graphics.Color
7
+ import androidx.annotation.ColorInt
8
+ import androidx.core.content.edit
9
+ import androidx.core.net.toUri
10
+ import kotlinx.serialization.json.Json
11
+ import kotlinx.serialization.json.JsonElement
12
+ import kotlinx.serialization.json.JsonObject
13
+ import kotlinx.serialization.json.JsonPrimitive
14
+ import kotlinx.serialization.json.booleanOrNull
15
+ import kotlinx.serialization.json.buildJsonObject
16
+ import kotlinx.serialization.json.contentOrNull
17
+ import kotlinx.serialization.json.doubleOrNull
18
+ import kotlinx.serialization.json.jsonArray
19
+ import kotlinx.serialization.json.jsonObject
20
+ import kotlinx.serialization.json.jsonPrimitive
21
+ import kotlinx.serialization.json.put
22
+
23
+ /**
24
+ * A colour that adapts to light/dark mode. Decodes either a single hex string
25
+ * (used for both appearances) or a `{"light": "#RRGGBB", "dark": "#RRGGBB"}`
26
+ * object, matching iOS's ThemedColor exactly — the same everywhere.json is
27
+ * stamped for both shells.
28
+ *
29
+ * Note the shell rarely needs these: the theme reads the same two colours from
30
+ * res/values/colors.xml, which the builder stamps at the same time. They exist
31
+ * for the places a theme attribute can't reach, such as a Custom Tab's toolbar.
32
+ */
33
+ class ThemedColor(private val light: String, private val dark: String?) {
34
+ @ColorInt
35
+ fun colorInt(context: Context): Int? {
36
+ val night = context.resources.configuration.uiMode and
37
+ Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
38
+ val hex = if (night) dark ?: light else light
39
+ return parseHex(hex)
40
+ }
41
+
42
+ companion object {
43
+ internal fun from(element: JsonElement?): ThemedColor? = when (element) {
44
+ null -> null
45
+ is JsonPrimitive -> element.contentOrNull?.let { ThemedColor(it, null) }
46
+ is JsonObject -> {
47
+ val light = element["light"]?.jsonPrimitive?.contentOrNull
48
+ val dark = element["dark"]?.jsonPrimitive?.contentOrNull
49
+ (light ?: dark)?.let { ThemedColor(it, dark ?: light) }
50
+ }
51
+ else -> null
52
+ }
53
+
54
+ /** Accepts `#RRGGBB` and `#RRGGBBAA` (leading `#` optional). */
55
+ @ColorInt
56
+ private fun parseHex(value: String): Int? {
57
+ val raw = value.trim().removePrefix("#")
58
+ if (raw.length != 6 && raw.length != 8) return null
59
+ val bits = raw.toLongOrNull(16) ?: return null
60
+ return if (raw.length == 8) {
61
+ // #RRGGBBAA on the wire, 0xAARRGGBB in Android.
62
+ val alpha = (bits and 0xFF).toInt()
63
+ Color.argb(alpha, ((bits shr 24) and 0xFF).toInt(), ((bits shr 16) and 0xFF).toInt(), ((bits shr 8) and 0xFF).toInt())
64
+ } else {
65
+ Color.rgb(((bits shr 16) and 0xFF).toInt(), ((bits shr 8) and 0xFF).toInt(), (bits and 0xFF).toInt())
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Third-party sign-in (everywhere.yml `auth:`): which paths hand off to a
73
+ * Custom Tab instead of loading in the web view, and the custom scheme the
74
+ * callback comes back through. Absent when the app declares no `auth:` — a
75
+ * shell that diverts nothing.
76
+ */
77
+ class AuthConfig(val oauthPaths: List<String>, val scheme: String) {
78
+ /**
79
+ * The patterns compiled once. They're regex sources, matched unanchored,
80
+ * exactly like the path-configuration `rules:` patterns they sit beside in
81
+ * everywhere.yml — and exactly like the gem matches them server-side, so
82
+ * both halves of the flow agree on what a provider path is.
83
+ */
84
+ private val expressions: List<Regex> = oauthPaths.mapNotNull {
85
+ runCatching { Regex(it) }.getOrNull()
86
+ }
87
+
88
+ /**
89
+ * Half a declaration is worse than none: without a scheme there's nothing
90
+ * for the callback to return through, and diverting a visit we can't
91
+ * finish would turn the sign-in button into a button that does nothing.
92
+ */
93
+ val isUsable: Boolean get() = scheme.isNotEmpty() && expressions.isNotEmpty()
94
+
95
+ fun matches(path: String): Boolean = expressions.any { it.containsMatchIn(path) }
96
+ }
97
+
98
+ /**
99
+ * The app's configuration, stamped into `assets/everywhere.json` by
100
+ * `every build`. The raw template ships a placeholder pointing at
101
+ * rubyeverywhere.com.
102
+ *
103
+ * Parsed from [JsonObject] rather than through `@Serializable` data classes on
104
+ * purpose: `tint_color` is either a string or an object, `permissions` may be
105
+ * absent entirely, and the gem adds keys (desktop `menu:`, `tray:`) that this
106
+ * shell has no business knowing about. Hand-reading tolerates all three
107
+ * without a custom serializer per field.
108
+ */
109
+ class EverywhereConfig private constructor(
110
+ private val appContext: Context,
111
+ private val json: JsonObject
112
+ ) {
113
+ val name: String = json.string("name") ?: "RubyEverywhere"
114
+ val version: String? = json.string("version")
115
+ val entryPath: String? = json.string("entry_path")
116
+ val remoteInstances: Boolean = json["remote_instances"]?.jsonPrimitive?.booleanOrNull == true
117
+ val permissions: List<String> =
118
+ json["permissions"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
119
+ val universalLinkHosts: List<String> =
120
+ json["universal_link_hosts"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
121
+
122
+ val tintColor: ThemedColor? = ThemedColor.from(json["tint_color"])
123
+ val backgroundColor: ThemedColor? = ThemedColor.from(json["background_color"])
124
+
125
+ private val stampedRemoteUrl: String? = json.string("remote_url")
126
+
127
+ private val auth: AuthConfig? = json["auth"]?.jsonObject?.let { obj ->
128
+ AuthConfig(
129
+ oauthPaths = obj["oauth_paths"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
130
+ scheme = obj.string("scheme") ?: ""
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Whether each non-selected tab defers its initial visit until first
136
+ * selected (everywhere.yml `lazy_load_tabs`).
137
+ *
138
+ * Unlike iOS this defaults to `true` when absent, and the shell passes
139
+ * `true` to the controller regardless — see [MainActivity]. Five pooled
140
+ * NavigatorHosts loading eagerly means five cold-boot WebViews and five
141
+ * start-location fetches at launch, including the hidden auth-gated ones.
142
+ * The value is still read so `lazy_load_tabs: false` is at least visible
143
+ * in the config, but Android cannot honour it.
144
+ */
145
+ val lazyLoadTabs: Boolean = json["lazy_load_tabs"]?.jsonPrimitive?.booleanOrNull ?: true
146
+
147
+ /** Minimum on-screen time for an app-provided splash, in milliseconds. */
148
+ val splashMinimumDisplay: Long =
149
+ ((json["splash_min_seconds"]?.jsonPrimitive?.doubleOrNull ?: 1.0) * 1000).toLong()
150
+
151
+ val userAgentPrefix: String =
152
+ version?.let { "RubyEverywhere/$it (android)" } ?: "RubyEverywhere (android)"
153
+
154
+ /**
155
+ * User-Agent for native HTTP requests ([everywhereFetch]): the shell prefix
156
+ * plus the Hotwire Native marker, so the gem's native_app?/native_platform/
157
+ * native_version helpers see native-screen requests exactly like web-view
158
+ * requests. The framework appends the marker itself for the WebView; a
159
+ * hand-built request has to say it.
160
+ */
161
+ val nativeUserAgent: String
162
+ get() = "$userAgentPrefix Hotwire Native Android"
163
+
164
+ /**
165
+ * Whether everywhere.yml declares this permission. Undeclared permissions
166
+ * must never reach the system APIs: Android only merges a
167
+ * `<uses-permission>` for declared ones, and requesting a permission the
168
+ * manifest doesn't hold fails silently — a denial the user never saw and
169
+ * can never undo.
170
+ */
171
+ fun declaresPermission(name: String): Boolean = permissions.contains(name)
172
+
173
+ // MARK: Roots
174
+
175
+ /**
176
+ * The root URL of the deployed app. A picked instance outranks the dev
177
+ * override on purpose: it's the more recent, in-app expression of intent,
178
+ * and dev-testing the picker flow only works if picking takes effect.
179
+ * `Everywhere.instance.clear()` falls back to the dev/stamped root.
180
+ */
181
+ val rootUrl: String
182
+ get() = instanceUrl ?: devUrl ?: stampedRemoteUrl
183
+ ?: error("remote.url not set in everywhere.yml — the Android shell is remote-mode only")
184
+
185
+ /** The first location the navigator visits: rootUrl joined with entry_path. */
186
+ val startUrl: String
187
+ get() {
188
+ val path = entryPath
189
+ return if (path.isNullOrEmpty() || path == "/") rootUrl else url(forPath = path)
190
+ }
191
+
192
+ /**
193
+ * The server half of the path configuration (rules + settings.tabs),
194
+ * generated from everywhere.yml by the gem's MobileConfigEndpoint — so tab
195
+ * changes deploy with the web app, no Play Store release.
196
+ */
197
+ val pathConfigurationUrl: String
198
+ get() = url(forPath = "/everywhere/android_v1.json")
199
+
200
+ /** rootUrl joined with an absolute-style path (leading `/` optional). */
201
+ fun url(forPath: String): String {
202
+ val base = rootUrl.trimEnd('/')
203
+ val suffix = if (forPath.startsWith("/")) forPath else "/$forPath"
204
+ return base + suffix
205
+ }
206
+
207
+ /**
208
+ * The path (plus query) of an absolute URL, for re-routing a deep link
209
+ * through the app's own effective root (dev override / picked instance).
210
+ */
211
+ fun pathAndQuery(url: String): String {
212
+ val uri = url.toUri()
213
+ val path = uri.path?.takeIf { it.isNotEmpty() } ?: "/"
214
+ val query = uri.query
215
+ return if (query.isNullOrEmpty()) path else "$path?$query"
216
+ }
217
+
218
+ // MARK: Dev-server override
219
+
220
+ /**
221
+ * The dev-server URL, in debug builds only.
222
+ *
223
+ * Android has no `SIMCTL_CHILD_*`, so there is nothing to read from the
224
+ * launching environment: `every dev --android` stamps
225
+ * `src/debug/assets/dev-url.txt` and rebuilds, which it has to do anyway.
226
+ * Baked into the APK means it also survives a relaunch from the launcher,
227
+ * which is the case iOS needs UserDefaults to paper over. Release builds
228
+ * never look for the file, and the debug source set never ships in one.
229
+ */
230
+ private val devUrl: String? by lazy {
231
+ if (!BuildConfig.DEBUG) return@lazy null
232
+ runCatching {
233
+ appContext.assets.open(DEV_URL_ASSET).bufferedReader().use { it.readText() }.trim()
234
+ }.getOrNull()?.takeIf { it.isNotEmpty() }
235
+ }
236
+
237
+ // MARK: Instance override (multi-instance apps)
238
+
239
+ private val prefs: SharedPreferences
240
+ get() = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
241
+
242
+ /**
243
+ * The persisted instance override. Read per-access, never cached:
244
+ * setInstance changes it mid-run and everything derived from rootUrl (start
245
+ * URL, path-configuration URL) must follow immediately.
246
+ */
247
+ val instanceUrl: String?
248
+ get() = if (!remoteInstances) null else prefs.getString(INSTANCE_KEY, null)
249
+
250
+ /**
251
+ * Persist (null clears) the instance override. Only absolute http(s) roots
252
+ * are accepted — the page posts arbitrary strings at the control channel,
253
+ * and re-rooting the whole app is the one place that must not take them on
254
+ * faith. (http stays allowed for LAN-hosted instances.)
255
+ */
256
+ fun setInstanceUrl(url: String?): Boolean {
257
+ if (url == null) {
258
+ prefs.edit { remove(INSTANCE_KEY) }
259
+ return true
260
+ }
261
+ val uri = url.toUri()
262
+ val scheme = uri.scheme?.lowercase()
263
+ if (scheme != "https" && scheme != "http") return false
264
+ if (uri.host.isNullOrEmpty()) return false
265
+
266
+ prefs.edit { putString(INSTANCE_KEY, url) }
267
+ return true
268
+ }
269
+
270
+ // MARK: Third-party sign-in
271
+
272
+ val authScheme: String? get() = auth?.takeIf { it.isUsable }?.scheme
273
+
274
+ /**
275
+ * The path a visit should hand to the auth flow, or null to let it load in
276
+ * the web view. Two shapes: a provider path the app linked to directly, and
277
+ * the gem's /everywhere/auth/native page, which is where the server sends
278
+ * visits the shell couldn't intercept (a `data-turbo="false"` link, or the
279
+ * POST OmniAuth 2 requires) — its `to` names the real provider path.
280
+ */
281
+ fun authTarget(url: String): String? {
282
+ val config = auth?.takeIf { it.isUsable } ?: return null
283
+ val uri = url.toUri()
284
+ val path = uri.path ?: return null
285
+
286
+ if (path == "/everywhere/auth/native") {
287
+ val to = uri.getQueryParameter("to")
288
+ return to?.takeIf { it.startsWith("/") && !it.startsWith("//") }
289
+ }
290
+ if (!config.matches(path)) return null
291
+
292
+ return pathAndQuery(url)
293
+ }
294
+
295
+ /**
296
+ * The first page of the auth flow: the gem's start endpoint, which marks
297
+ * the browser's cookie jar as ours and continues into `to`. `returnTo` is
298
+ * where the user was, used only if the app's own sign-in doesn't say where
299
+ * to land.
300
+ */
301
+ fun authStartUrl(to: String, returnTo: String?): String {
302
+ val builder = url(forPath = "/everywhere/auth/start").toUri().buildUpon()
303
+ builder.appendQueryParameter("to", to)
304
+ returnTo?.let { builder.appendQueryParameter("return_to", it) }
305
+ return builder.build().toString()
306
+ }
307
+
308
+ /**
309
+ * Where the token gets spent. Visited in the web view — that's the whole
310
+ * point, the response's cookies have to land in the web view's jar — and it
311
+ * redirects on to wherever the app's sign-in wanted the user.
312
+ */
313
+ fun authHandoffUrl(token: String): String =
314
+ url(forPath = "/everywhere/auth/handoff").toUri().buildUpon()
315
+ .appendQueryParameter("token", token)
316
+ .build()
317
+ .toString()
318
+
319
+ // MARK: Deep links
320
+
321
+ /**
322
+ * Whether an incoming link belongs to this app: https, and its host is the
323
+ * app's own root host or one of the verified domains (everywhere.yml
324
+ * `deep_linking`, injected as `universal_link_hosts`). A link to some other
325
+ * site is left to the browser.
326
+ *
327
+ * The manifest's `autoVerify` intent filter has already narrowed this on
328
+ * the way in, but the filter is stamped from the same list and an
329
+ * Intent can be sent by any app on the device, so the check is repeated
330
+ * here rather than trusted.
331
+ */
332
+ fun handlesUniversalLink(url: String): Boolean {
333
+ val uri = url.toUri()
334
+ if (uri.scheme?.lowercase() != "https") return false
335
+ val host = uri.host?.lowercase() ?: return false
336
+ if (rootUrl.toUri().host?.lowercase() == host) return true
337
+ return universalLinkHosts.any { it.lowercase() == host }
338
+ }
339
+
340
+ // MARK: Page-visible config
341
+
342
+ /**
343
+ * A compact JSON object injected into every page as
344
+ * `window.__EVERYWHERE_CONFIG__`. Built per web view (not once at launch):
345
+ * `instance` must reflect the override as it stands when the post-reset web
346
+ * views are created.
347
+ */
348
+ val webConfigJson: String
349
+ get() = buildJsonObject {
350
+ put("name", name)
351
+ put("os", "android")
352
+ version?.let { put("version", it) }
353
+ if (remoteInstances) {
354
+ put("instances", true)
355
+ instanceUrl?.let { put("instance", it) }
356
+ }
357
+ }.toString()
358
+
359
+ companion object {
360
+ private const val ASSET = "everywhere.json"
361
+ private const val DEV_URL_ASSET = "dev-url.txt"
362
+ private const val PREFS = "everywhere"
363
+ private const val INSTANCE_KEY = "instance_url"
364
+
365
+ private val parser = Json { ignoreUnknownKeys = true; isLenient = true }
366
+
367
+ @Volatile
368
+ private var instance: EverywhereConfig? = null
369
+
370
+ /**
371
+ * Parsed once, from [EverywhereApplication.onCreate], before anything
372
+ * can ask for [shared].
373
+ */
374
+ fun initialize(context: Context) {
375
+ instance = load(context.applicationContext)
376
+ }
377
+
378
+ val shared: EverywhereConfig
379
+ get() = instance ?: error(
380
+ "EverywhereConfig was read before EverywhereApplication.onCreate ran."
381
+ )
382
+
383
+ private fun load(appContext: Context): EverywhereConfig {
384
+ val text = try {
385
+ appContext.assets.open(ASSET).bufferedReader().use { it.readText() }
386
+ } catch (e: Exception) {
387
+ // Deliberately fatal, like iOS. A shell with no config has no
388
+ // URL to load; failing at launch with the reason beats a blank
389
+ // web view and a support ticket.
390
+ throw IllegalStateException(
391
+ "$ASSET is missing from the app's assets — stamp this shell with `every build` before running it.",
392
+ e
393
+ )
394
+ }
395
+ val json = try {
396
+ parser.parseToJsonElement(text).jsonObject
397
+ } catch (e: Exception) {
398
+ throw IllegalStateException(
399
+ "$ASSET in the app's assets is invalid — re-stamp this shell with `every build`.",
400
+ e
401
+ )
402
+ }
403
+ return EverywhereConfig(appContext, json)
404
+ }
405
+
406
+ private fun JsonObject.string(key: String): String? =
407
+ this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotEmpty() }
408
+ }
409
+ }
@@ -0,0 +1,68 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import kotlinx.coroutines.flow.MutableSharedFlow
4
+
5
+ /**
6
+ * Page → shell control messages, in one place. The Android answer to iOS's
7
+ * NotificationCenter names (`.everywhereReloadConfig` and friends).
8
+ *
9
+ * The producer is [com.rubyeverywhere.shell.bridge.WebControlChannel], which
10
+ * receives `window.everywhereControl.postMessage({action: …})` from the page;
11
+ * the consumer is [MainActivity], which collects on the main dispatcher and is
12
+ * the only thing allowed to act on these. Keeping the channel decoupled from
13
+ * the Activity matters for the same reason it does on iOS: the web view (and
14
+ * the channel bound to it) is created by the Application before any Activity
15
+ * exists, and survives the Activity being recreated for a tab-path change.
16
+ *
17
+ * Buffered and non-replaying, deliberately:
18
+ * * `extraBufferCapacity` with DROP_OLDEST semantics via [tryEmit] means the
19
+ * channel is never suspended by a slow collector — it is called from the
20
+ * WebView's message thread, which must not block.
21
+ * * `replay = 0` because every event is an imperative command. Replaying
22
+ * "reset the app" to a collector that starts later would re-run it, and
23
+ * MainActivity re-collects every time it is recreated.
24
+ */
25
+ object EverywhereEvents {
26
+
27
+ sealed interface Event {
28
+ /** `{action: "reloadConfig"}` — refetch the auth-aware path configuration. */
29
+ data object ReloadConfig : Event
30
+
31
+ /** `{action: "reset", to: "/path"}` — full app reset, then land on `to`. */
32
+ data class ResetApp(val to: String?) : Event
33
+
34
+ /** `{action: "setTabBadge", path: "/x", count: 3}` — 0 clears. */
35
+ data class SetTabBadge(val path: String, val count: Int) : Event
36
+
37
+ /** `{action: "setBadge", count: 3}` — the launcher icon badge; 0 clears. */
38
+ data class SetBadge(val count: Int) : Event
39
+
40
+ /** `{action: "setInstance", url: "https://…", to: "/path"?}` */
41
+ data class SetInstance(val url: String, val to: String?) : Event
42
+
43
+ /** `{action: "clearInstance", to: "/path"?}` */
44
+ data class ClearInstance(val to: String?) : Event
45
+
46
+ /** `everywhereVisit(path)` from native extension code. */
47
+ data class NativeVisit(val path: String) : Event
48
+
49
+ /** `{action: "authFlow", to: "/auth/github"}` */
50
+ data class AuthFlow(val to: String) : Event
51
+ }
52
+
53
+ /**
54
+ * Mutable on purpose, and public. The control channel emits from a
55
+ * coroutine so it can await a slow collector rather than drop; the route
56
+ * decision handlers, which run on a framework callback that cannot suspend,
57
+ * use [emit] instead. Both need to write, so there is nothing to hide
58
+ * behind an `asSharedFlow()`.
59
+ */
60
+ val events = MutableSharedFlow<Event>(replay = 0, extraBufferCapacity = 32)
61
+
62
+ /**
63
+ * Non-suspending, callable from any thread. Returns false only if the
64
+ * buffer is full — 32 pending control messages means nothing is collecting,
65
+ * in which case dropping is the right answer, not blocking the caller.
66
+ */
67
+ fun emit(event: Event): Boolean = events.tryEmit(event)
68
+ }
@@ -0,0 +1,101 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import android.webkit.CookieManager
4
+ import kotlinx.coroutines.Dispatchers
5
+ import kotlinx.coroutines.withContext
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+
9
+ // Note for whoever edits the comments in this file: Kotlin block comments NEST,
10
+ // unlike Java's, so a "slash star" sequence inside a KDoc block — which is what
11
+ // a glob like the one naming this app's Kotlin sources would be — opens a nested
12
+ // comment and swallows the rest of the file. It fails as "Unclosed comment" at
13
+ // the last line, naming nothing. Globs belong in line comments only.
14
+
15
+ /**
16
+ * The two things app-authored native code in the app repo's native/android
17
+ * directory needs from the shell, and the Android half of iOS's
18
+ * EverywhereHost.swift. Deliberately top-level functions with the same names as
19
+ * the Swift ones, so the two platforms' extension code reads the same.
20
+ *
21
+ * There is no `everywhereHost` counterpart: iOS needs one to lift a SwiftUI
22
+ * view into a UIViewController, while a Hotwire Android destination is already
23
+ * a Fragment.
24
+ */
25
+
26
+ /**
27
+ * Route the app to a path (or absolute URL) from native extension code — the
28
+ * native counterpart of `Everywhere.visit()`. Relative paths resolve against
29
+ * the effective root (dev override / picked instance included); web routes load
30
+ * in the web view, native-screen routes push their screen.
31
+ *
32
+ * Non-suspending and safe from any thread: it hands the path to
33
+ * [EverywhereEvents], which [MainActivity] collects on the main dispatcher.
34
+ */
35
+ fun everywhereVisit(path: String) {
36
+ EverywhereEvents.emit(EverywhereEvents.Event.NativeVisit(path))
37
+ }
38
+
39
+ /**
40
+ * GET a path (or absolute URL) from the app's own server and return the body —
41
+ * for native screens that render the app's data instead of its HTML. The
42
+ * counterpart of iOS's `everywhereURLSession`, narrowed to the one call that
43
+ * needs shell knowledge to get right:
44
+ *
45
+ * * Relative paths resolve against the effective root, so a native screen
46
+ * follows a dev override or a picked instance like every web visit does.
47
+ * * The session cookie is copied from the WebView's store, so the request
48
+ * rides the signed-in session. Android's CookieManager is one process-wide
49
+ * store, so this is a synchronous read (on iOS it is a whole round trip).
50
+ * * The User-Agent carries the Hotwire Native marker, so the gem's
51
+ * `native_app?` / `native_platform` / `native_version` helpers answer for
52
+ * native-screen requests exactly as they do for web-view requests.
53
+ *
54
+ * Suspending, and switched to Dispatchers.IO: a blocking call on the main
55
+ * thread would raise NetworkOnMainThreadException. Throws on a transport
56
+ * failure or a non-2xx status — a native screen has to render *something* for a
57
+ * signed-out or erroring server, and swallowing that here would leave it
58
+ * rendering an empty list with no way to tell why.
59
+ */
60
+ suspend fun everywhereFetch(path: String, accept: String = "application/json"): String =
61
+ withContext(Dispatchers.IO) {
62
+ val config = EverywhereConfig.shared
63
+ val location = if (path.startsWith("http://") || path.startsWith("https://")) {
64
+ path
65
+ } else {
66
+ config.url(forPath = path)
67
+ }
68
+
69
+ val connection = (URL(location).openConnection() as HttpURLConnection).apply {
70
+ requestMethod = "GET"
71
+ setRequestProperty("Accept", accept)
72
+ setRequestProperty("User-Agent", config.nativeUserAgent)
73
+ runCatching { CookieManager.getInstance().getCookie(location) }.getOrNull()
74
+ ?.takeIf { it.isNotEmpty() }
75
+ ?.let { setRequestProperty("Cookie", it) }
76
+ connectTimeout = TIMEOUT_MS
77
+ readTimeout = TIMEOUT_MS
78
+ }
79
+
80
+ try {
81
+ val status = connection.responseCode
82
+ if (status !in 200..299) {
83
+ // errorStream, not inputStream: reading the latter on a 4xx/5xx
84
+ // throws, which would hide the status the caller needs to report.
85
+ val detail = connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
86
+ throw EverywhereFetchError(status, location, detail)
87
+ }
88
+ connection.inputStream.bufferedReader().use { it.readText() }
89
+ } finally {
90
+ connection.disconnect()
91
+ }
92
+ }
93
+
94
+ /** A non-2xx response from [everywhereFetch]. */
95
+ class EverywhereFetchError(
96
+ val status: Int,
97
+ val location: String,
98
+ val body: String
99
+ ) : Exception("HTTP $status for $location")
100
+
101
+ private const val TIMEOUT_MS = 15_000