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,271 @@
1
+ package com.rubyeverywhere.shell.bridge
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.SharedPreferences
8
+ import android.content.pm.PackageManager
9
+ import android.net.Uri
10
+ import android.os.Build
11
+ import android.provider.Settings
12
+ import android.util.Log
13
+ import androidx.activity.ComponentActivity
14
+ import androidx.activity.result.ActivityResultLauncher
15
+ import androidx.activity.result.contract.ActivityResultContracts
16
+ import androidx.core.app.ActivityCompat
17
+ import androidx.core.app.NotificationManagerCompat
18
+ import androidx.core.content.ContextCompat
19
+ import androidx.fragment.app.Fragment
20
+ import com.rubyeverywhere.shell.EverywhereConfig
21
+ import dev.hotwire.core.bridge.BridgeComponent
22
+ import dev.hotwire.core.bridge.BridgeDelegate
23
+ import dev.hotwire.core.bridge.Message
24
+ import dev.hotwire.navigation.destinations.HotwireDestination
25
+ import kotlinx.serialization.SerialName
26
+ import kotlinx.serialization.Serializable
27
+ import java.util.concurrent.atomic.AtomicInteger
28
+
29
+ /**
30
+ * Bridge component backing `everywhere--permissions`. Handles `query` and
31
+ * `request` for notifications / camera / location, replying `{name, status}`
32
+ * with granted | denied | prompt | undeclared | unsupported, and `openSettings`
33
+ * for the denied dead-end — the same events, keys and vocabulary as iOS's
34
+ * PermissionsComponent.swift.
35
+ *
36
+ * Everything must be declared in everywhere.yml, and an undeclared name
37
+ * short-circuits to `undeclared` before touching a system API. On iOS that gate
38
+ * is load-bearing because a prompt without an Info.plist usage string kills the
39
+ * process; Android just prompts, so the gate is doing something different and
40
+ * arguably more important here — it is the *promise* that an app which never
41
+ * declared `camera:` can never be talked into asking for the camera by a page.
42
+ * Weakening it on Android would make the same everywhere.yml mean two things.
43
+ *
44
+ * Notifications are the one exception, and it is iOS's exception, kept
45
+ * deliberately: they carry no usage string there, so they were never gated. The
46
+ * Android analogue of the gate for them is the manifest — `POST_NOTIFICATIONS`
47
+ * is only stamped when everywhere.yml declares `notifications:` — and an
48
+ * undeclared app is answered from the real notification state rather than being
49
+ * told it can prompt when it can't.
50
+ *
51
+ * ### `denied` means what it means on iOS
52
+ *
53
+ * iOS never re-prompts, so `denied` is final and `openSettings()` is the only
54
+ * way out. Android's own APIs can't distinguish "never asked" from "asked and
55
+ * permanently refused" — `shouldShowRequestPermissionRationale` is false for
56
+ * both — so the shell remembers that it asked. Never-asked and asked-once both
57
+ * answer `prompt` (they can still be prompted); asked-and-no-longer-promptable
58
+ * answers `denied`, which is exactly the state where the page should be offering
59
+ * `openSettings()` instead of asking again.
60
+ */
61
+ class PermissionsComponent(
62
+ name: String,
63
+ private val delegate: BridgeDelegate<HotwireDestination>
64
+ ) : BridgeComponent<HotwireDestination>(name, delegate) {
65
+
66
+ private val fragment: Fragment
67
+ get() = delegate.destination.fragment
68
+
69
+ override fun onReceive(message: Message) {
70
+ when (message.event) {
71
+ "query", "request" -> {
72
+ val data = message.data<Payload>() ?: return
73
+ handle(message, data.name)
74
+ }
75
+ "openSettings" -> openSettings()
76
+ else -> Log.w(TAG, "Unknown event for message: $message")
77
+ }
78
+ }
79
+
80
+ private fun handle(message: Message, permission: String) {
81
+ val context = fragment.context ?: return
82
+ val activity = fragment.activity ?: return
83
+
84
+ if (permission != "notifications" && !EverywhereConfig.shared.declaresPermission(permission)) {
85
+ return reply(message, permission, "undeclared")
86
+ }
87
+
88
+ val request = message.event == "request"
89
+ when (permission) {
90
+ "notifications" -> notifications(context, activity, request) { reply(message, permission, it) }
91
+ "camera" -> runtime(context, activity, Manifest.permission.CAMERA, request) {
92
+ reply(message, permission, it)
93
+ }
94
+ "location" -> runtime(context, activity, Manifest.permission.ACCESS_FINE_LOCATION, request) {
95
+ reply(message, permission, it)
96
+ }
97
+ // `biometrics` lives in everywhere.yml's permissions list too, but it
98
+ // is not a runtime permission — the page asks
99
+ // Everywhere.biometrics.available() instead. Same answer as iOS.
100
+ else -> reply(message, permission, "unsupported")
101
+ }
102
+ }
103
+
104
+ // MARK: Notifications
105
+
106
+ private fun notifications(
107
+ context: Context,
108
+ activity: Activity,
109
+ request: Boolean,
110
+ completion: (String) -> Unit
111
+ ) {
112
+ val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
113
+
114
+ // Below API 33 there is no permission to hold: notifications are on
115
+ // unless the user turned them off in settings, and nothing can prompt.
116
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
117
+ return completion(if (enabled) "granted" else "denied")
118
+ }
119
+
120
+ // Declared in everywhere.yml → POST_NOTIFICATIONS is in the manifest and
121
+ // this is an ordinary runtime permission. Undeclared → it isn't, a
122
+ // request would be refused without any UI, so report the real state.
123
+ if (!declaredInManifest(context, POST_NOTIFICATIONS)) {
124
+ return completion(if (enabled) "granted" else "denied")
125
+ }
126
+
127
+ runtime(context, activity, POST_NOTIFICATIONS, request, completion)
128
+ }
129
+
130
+ // MARK: Runtime permissions
131
+
132
+ private fun runtime(
133
+ context: Context,
134
+ activity: Activity,
135
+ permission: String,
136
+ request: Boolean,
137
+ completion: (String) -> Unit
138
+ ) {
139
+ val status = status(context, activity, permission)
140
+ if (!request || status != "prompt") return completion(status)
141
+
142
+ RuntimePermissions.request(fragment, permission) { granted ->
143
+ // Re-derive rather than trusting the boolean: a refusal that used up
144
+ // the last prompt has to read as `denied`, not as another `prompt`.
145
+ completion(if (granted) "granted" else status(context, activity, permission))
146
+ }
147
+ }
148
+
149
+ private fun status(context: Context, activity: Activity, permission: String): String {
150
+ if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
151
+ return "granted"
152
+ }
153
+ // The system is offering to explain itself, which means it will still
154
+ // show the dialog — promptable.
155
+ if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) return "prompt"
156
+
157
+ return if (RuntimePermissions.hasAsked(context, permission)) "denied" else "prompt"
158
+ }
159
+
160
+ @Suppress("DEPRECATION")
161
+ private fun declaredInManifest(context: Context, permission: String): Boolean = try {
162
+ context.packageManager
163
+ .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
164
+ .requestedPermissions
165
+ ?.contains(permission) == true
166
+ } catch (e: Exception) {
167
+ Log.w(TAG, "could not read the manifest permissions", e)
168
+ false
169
+ }
170
+
171
+ // MARK: Settings
172
+
173
+ /**
174
+ * The denied dead-end. iOS opens the app's settings pane; Android's
175
+ * equivalent is the app details screen, which is where the permission
176
+ * toggles live.
177
+ */
178
+ private fun openSettings() {
179
+ val context = fragment.context ?: return
180
+ val intent = Intent(
181
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
182
+ Uri.fromParts("package", context.packageName, null)
183
+ )
184
+ try {
185
+ fragment.startActivity(intent)
186
+ } catch (e: Exception) {
187
+ Log.w(TAG, "could not open app settings", e)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Replies on the exact message that asked. Concurrent messages for the same
193
+ * event (every permission row queries on page load at once) make
194
+ * `replyTo(event)` unusable — it targets only the last-received message.
195
+ * Same reasoning, same fix, as iOS.
196
+ */
197
+ private fun reply(message: Message, name: String, status: String) {
198
+ replyWith(message.replacing(event = message.event, data = StatusReply(name = name, status = status)))
199
+ }
200
+
201
+ @Serializable
202
+ data class Payload(
203
+ @SerialName("name") val name: String
204
+ )
205
+
206
+ @Serializable
207
+ data class StatusReply(
208
+ @SerialName("name") val name: String,
209
+ @SerialName("status") val status: String
210
+ )
211
+
212
+ private companion object {
213
+ const val TAG = "EverywherePermissions"
214
+ const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Runtime permission prompts for the bridge components, plus the "we have asked
220
+ * before" memory that makes a permanent refusal distinguishable from a fresh
221
+ * one.
222
+ *
223
+ * `Fragment.registerForActivityResult` is unusable here: it must be called
224
+ * before the fragment reaches STARTED, and a bridge component is created lazily
225
+ * when its first message arrives — long after. `ActivityResultRegistry.register`
226
+ * (the overload without a LifecycleOwner) is the documented escape hatch for
227
+ * exactly this, and it is why the launcher is unregistered by hand.
228
+ *
229
+ * Known limit, shared with iOS's own force-kill case: a configuration change or
230
+ * process death while the system dialog is up loses the callback, so the web
231
+ * promise never settles. Re-registering under a stable key would need state the
232
+ * bridge message doesn't carry, and the page recovers on its next query().
233
+ *
234
+ * Shared with NotificationComponent, which asks for POST_NOTIFICATIONS inside
235
+ * notify() the way iOS asks for authorization inside its own notify().
236
+ */
237
+ internal object RuntimePermissions {
238
+ private const val PREFS = "everywhere.permissions"
239
+ private const val ASKED_PREFIX = "asked."
240
+
241
+ private val sequence = AtomicInteger(0)
242
+
243
+ fun request(fragment: Fragment, permission: String, onResult: (Boolean) -> Unit) {
244
+ val activity = fragment.activity as? ComponentActivity ?: return onResult(false)
245
+
246
+ // Recorded before launching, not after: if the process dies while the
247
+ // dialog is up, the user was still asked, and forgetting that would make
248
+ // a permanent refusal look promptable forever.
249
+ markAsked(activity, permission)
250
+
251
+ var launcher: ActivityResultLauncher<String>? = null
252
+ launcher = activity.activityResultRegistry.register(
253
+ "everywhere-permission:$permission:${sequence.incrementAndGet()}",
254
+ ActivityResultContracts.RequestPermission()
255
+ ) { granted ->
256
+ launcher?.unregister()
257
+ onResult(granted)
258
+ }
259
+ launcher.launch(permission)
260
+ }
261
+
262
+ fun hasAsked(context: Context, permission: String): Boolean =
263
+ prefs(context).getBoolean(ASKED_PREFIX + permission, false)
264
+
265
+ private fun markAsked(context: Context, permission: String) {
266
+ prefs(context).edit().putBoolean(ASKED_PREFIX + permission, true).apply()
267
+ }
268
+
269
+ private fun prefs(context: Context): SharedPreferences =
270
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
271
+ }
@@ -0,0 +1,101 @@
1
+ package com.rubyeverywhere.shell.bridge
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.util.Log
6
+ import dev.hotwire.core.bridge.BridgeComponent
7
+ import dev.hotwire.core.bridge.BridgeDelegate
8
+ import dev.hotwire.core.bridge.Message
9
+ import dev.hotwire.navigation.destinations.HotwireDestination
10
+ import kotlinx.serialization.SerialName
11
+ import kotlinx.serialization.Serializable
12
+
13
+ /**
14
+ * Bridge component backing `everywhere--storage`: device-local key/value
15
+ * settings persisted in SharedPreferences for `Everywhere.storage.*` — the
16
+ * Android stand-in for iOS's UserDefaults, and for the same reason: it outlives
17
+ * web view resets and cache clears, which localStorage does not.
18
+ *
19
+ * Values ride the wire as JSON-encoded strings (the JS side owns encoding and
20
+ * decoding), so any JSON value round-trips without a typed schema here. Four
21
+ * events — `get` `set` `remove` `clear` — replying `{ok}` and, for `get`,
22
+ * `{ok, value}`. Identical to iOS's StorageComponent.swift.
23
+ *
24
+ * Keys keep iOS's "everywhere.app." prefix even though they live in their own
25
+ * preferences file: `clear()` is defined as "the app's keys, not the shell's",
26
+ * and having both platforms express that the same way means one description of
27
+ * the storage layout is true on both.
28
+ *
29
+ * Settings, not secrets — server tokens belong in `biometrics.credential`.
30
+ */
31
+ class StorageComponent(
32
+ name: String,
33
+ private val delegate: BridgeDelegate<HotwireDestination>
34
+ ) : BridgeComponent<HotwireDestination>(name, delegate) {
35
+
36
+ private val prefs: SharedPreferences?
37
+ get() = delegate.destination.fragment.context
38
+ ?.applicationContext
39
+ ?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
40
+
41
+ override fun onReceive(message: Message) {
42
+ val prefs = prefs ?: return reply(message, Reply(ok = false))
43
+ val data = message.data<Payload>()
44
+
45
+ when (message.event) {
46
+ "get" -> {
47
+ val key = namespaced(data) ?: return reply(message, Reply(ok = false))
48
+ reply(message, Reply(ok = true, value = prefs.getString(key, null)))
49
+ }
50
+ "set" -> {
51
+ val key = namespaced(data)
52
+ val value = data?.value
53
+ if (key == null || value == null) return reply(message, Reply(ok = false))
54
+ prefs.edit().putString(key, value).apply()
55
+ reply(message, Reply(ok = true))
56
+ }
57
+ "remove" -> {
58
+ val key = namespaced(data) ?: return reply(message, Reply(ok = false))
59
+ prefs.edit().remove(key).apply()
60
+ reply(message, Reply(ok = true))
61
+ }
62
+ "clear" -> {
63
+ val editor = prefs.edit()
64
+ prefs.all.keys.filter { it.startsWith(PREFIX) }.forEach { editor.remove(it) }
65
+ editor.apply()
66
+ reply(message, Reply(ok = true))
67
+ }
68
+ else -> Log.w(TAG, "Unknown event for message: $message")
69
+ }
70
+ }
71
+
72
+ private fun namespaced(data: Payload?): String? =
73
+ data?.key?.takeIf { it.isNotEmpty() }?.let { PREFIX + it }
74
+
75
+ /**
76
+ * Replies on the exact message that asked — a page that reads three
77
+ * settings at once has three `get` messages in flight, and `replyTo(event)`
78
+ * would answer all of them with the last one's key.
79
+ */
80
+ private fun reply(message: Message, payload: Reply) {
81
+ replyWith(message.replacing(event = message.event, data = payload))
82
+ }
83
+
84
+ @Serializable
85
+ data class Payload(
86
+ @SerialName("key") val key: String? = null,
87
+ @SerialName("value") val value: String? = null
88
+ )
89
+
90
+ @Serializable
91
+ data class Reply(
92
+ @SerialName("ok") val ok: Boolean,
93
+ @SerialName("value") val value: String? = null
94
+ )
95
+
96
+ private companion object {
97
+ const val TAG = "EverywhereStorage"
98
+ const val PREFS = "everywhere.storage"
99
+ const val PREFIX = "everywhere.app."
100
+ }
101
+ }
@@ -0,0 +1,204 @@
1
+ package com.rubyeverywhere.shell.bridge
2
+
3
+ import android.content.pm.ApplicationInfo
4
+ import android.net.Uri
5
+ import android.util.Log
6
+ import android.webkit.WebView
7
+ import androidx.core.net.toUri
8
+ import androidx.webkit.JavaScriptReplyProxy
9
+ import androidx.webkit.WebMessageCompat
10
+ import androidx.webkit.WebViewCompat
11
+ import androidx.webkit.WebViewFeature
12
+ import com.rubyeverywhere.shell.EverywhereConfig
13
+ import com.rubyeverywhere.shell.EverywhereEvents
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * The page → shell control channel: app-level actions (reset, tabs, badges,
18
+ * instance, auth) that must work on any page, whether or not a bridge component
19
+ * happens to be mounted on it. The Android half of iOS's
20
+ * `WKScriptMessageHandler` (see AppDelegate.WebControlHandler).
21
+ *
22
+ * { action: "reloadConfig" } → refresh tabs/path config
23
+ * { action: "reset", to: "/path" } → full app reset, then land on `to`
24
+ * { action: "setBadge", count: 3 } → app icon badge (0 clears)
25
+ * { action: "setTabBadge", path: "/x", count: 3 } → tab bar badge (0 clears)
26
+ * { action: "setInstance", url: "https://…", to: "/path"? } → persist instance root + reset
27
+ * { action: "clearInstance", to: "/path"? } → back to the stamped root + reset
28
+ * { action: "authFlow", to: "/auth/github" } → provider sign-in in the system browser
29
+ *
30
+ * Two things differ from iOS and both are forced by the platform:
31
+ *
32
+ * 1. **A web message is text.** `WKUserContentController` serializes the object
33
+ * `postMessage` was handed and delivers a dictionary;
34
+ * `WebViewCompat.addWebMessageListener` is backed by a `WebMessagePort`,
35
+ * which carries a `String`. bridge.js already knows this — its
36
+ * `nativeControlChannel()` JSON.stringify's for the Android branch — so the
37
+ * work here is parsing that JSON back into the same seven shapes.
38
+ * 2. **No NotificationCenter.** Actions are emitted onto [EverywhereEvents],
39
+ * which MainActivity collects. Same fan-out, one subscriber.
40
+ *
41
+ * Holds no reference to a WebView or a navigator, so retaining one per web view
42
+ * can't cycle.
43
+ */
44
+ class WebControlChannel : WebViewCompat.WebMessageListener {
45
+
46
+ override fun onPostMessage(
47
+ view: WebView,
48
+ message: WebMessageCompat,
49
+ sourceOrigin: Uri,
50
+ isMainFrame: Boolean,
51
+ replyProxy: JavaScriptReplyProxy
52
+ ) {
53
+ // Sub-frames must never re-root the app: an embedded iframe is not the
54
+ // page the user is on, and `setInstance`/`reset` are app-level.
55
+ if (!isMainFrame) return
56
+
57
+ val body = message.data?.let { parse(it) } ?: return
58
+ val action = body.optString("action").takeIf { it.isNotEmpty() } ?: return
59
+
60
+ // The debuggable flag rather than BuildConfig.DEBUG: this file must not
61
+ // assume the frozen build script turned `buildConfig` on.
62
+ if (view.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
63
+ Log.d(TAG, "everywhereControl: $body")
64
+ }
65
+
66
+ when (action) {
67
+ "reloadConfig" ->
68
+ EverywhereEvents.emit(EverywhereEvents.Event.ReloadConfig)
69
+
70
+ "reset" ->
71
+ EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(body.optPath("to")))
72
+
73
+ "setBadge" ->
74
+ EverywhereEvents.emit(
75
+ EverywhereEvents.Event.SetBadge(body.optInt("count", 0).coerceAtLeast(0))
76
+ )
77
+
78
+ "setTabBadge" -> {
79
+ val path = body.optString("path").takeIf { it.isNotEmpty() } ?: return
80
+ EverywhereEvents.emit(
81
+ EverywhereEvents.Event.SetTabBadge(
82
+ path = path,
83
+ count = body.optInt("count", 0).coerceAtLeast(0)
84
+ )
85
+ )
86
+ }
87
+
88
+ "setInstance" -> {
89
+ // Validating the URL itself belongs with the persistence
90
+ // (EverywhereConfig.setInstanceUrl), exactly as on iOS — this
91
+ // only refuses the empty case, so a blank string can't re-root.
92
+ val url = body.optString("url").takeIf { it.isNotEmpty() } ?: return
93
+ EverywhereEvents.emit(
94
+ EverywhereEvents.Event.SetInstance(url = url, to = body.optPath("to"))
95
+ )
96
+ }
97
+
98
+ "clearInstance" ->
99
+ EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(body.optPath("to")))
100
+
101
+ "authFlow" -> {
102
+ // Same guard as iOS's handleAuthFlow: an absolute same-origin
103
+ // path only, never a protocol-relative "//evil.example" that
104
+ // would send the user's sign-in to somebody else's host.
105
+ val to = body.optPath("to") ?: return
106
+ EverywhereEvents.emit(EverywhereEvents.Event.AuthFlow(to))
107
+ }
108
+
109
+ else -> Log.w(TAG, "everywhereControl: unknown action '$action'")
110
+ }
111
+ }
112
+
113
+ private fun parse(data: String): JSONObject? = try {
114
+ JSONObject(data)
115
+ } catch (e: Exception) {
116
+ Log.w(TAG, "everywhereControl: unparseable message", e)
117
+ null
118
+ }
119
+
120
+ /** An absolute, same-origin path, or null — the only `to` worth acting on. */
121
+ private fun JSONObject.optPath(name: String): String? =
122
+ optString(name).takeIf { it.startsWith("/") && !it.startsWith("//") }
123
+
124
+ companion object {
125
+ private const val TAG = "EverywhereControl"
126
+
127
+ /** The name bridge.js looks for: `window.everywhereControl`. */
128
+ const val JS_OBJECT_NAME = "everywhereControl"
129
+
130
+ /**
131
+ * Everything iOS does with `WKUserContentController`, done per web view
132
+ * because Android has no user-script API: inject
133
+ * `window.__EVERYWHERE_CONFIG__` before the page's own scripts run, and
134
+ * expose the control channel object bridge.js posts to.
135
+ *
136
+ * Both are `androidx.webkit` features a device's WebView provider can be
137
+ * too old for, and both degrade honestly rather than throwing:
138
+ *
139
+ * * no DOCUMENT_START_SCRIPT — the page loses the app's name, version
140
+ * and instance, not its ability to run (bridge.js reads the object
141
+ * defensively and falls back to the browser adapter).
142
+ * * no WEB_MESSAGE_LISTENER — `nativeControlChannel()` finds nothing,
143
+ * so `reloadTabs()` logs a debug line and `resetApp()` becomes a
144
+ * plain navigation. Worth a warning in the log, because the shell
145
+ * silently loses tab refreshes and instance switching.
146
+ *
147
+ * Both are scoped to the app's own origin. `"*"` would hand every page
148
+ * the app ever loads — including anything a link leads to — the ability
149
+ * to re-root the shell.
150
+ *
151
+ * @param webConfigJson the object literal for `window.__EVERYWHERE_CONFIG__`,
152
+ * rebuilt per web view because it carries the instance override, which
153
+ * changes when a picker re-roots the app.
154
+ */
155
+ fun install(webView: WebView, webConfigJson: String) {
156
+ val origins = originRules()
157
+
158
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
159
+ runCatching {
160
+ WebViewCompat.addDocumentStartJavaScript(
161
+ webView,
162
+ "window.__EVERYWHERE_CONFIG__ = $webConfigJson;",
163
+ origins
164
+ )
165
+ }.onFailure { Log.w(TAG, "could not inject the page config", it) }
166
+ }
167
+
168
+ if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
169
+ Log.w(
170
+ TAG,
171
+ "WEB_MESSAGE_LISTENER unsupported by this WebView provider — reloadTabs, " +
172
+ "reset, badges, instance switching and native sign-in fall back to their web behaviour"
173
+ )
174
+ return
175
+ }
176
+
177
+ runCatching {
178
+ WebViewCompat.addWebMessageListener(
179
+ webView,
180
+ JS_OBJECT_NAME,
181
+ origins,
182
+ WebControlChannel()
183
+ )
184
+ }.onFailure {
185
+ // addWebMessageListener throws on a malformed origin rule; a
186
+ // typo'd root URL must not take the shell down at launch.
187
+ Log.e(TAG, "could not install the control channel", it)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The app's own origin, derived from the *effective* root — so a dev
193
+ * build against 127.0.0.1 and a picked instance both work without a
194
+ * second list to keep in sync.
195
+ */
196
+ private fun originRules(): Set<String> {
197
+ val uri = EverywhereConfig.shared.rootUrl.toUri()
198
+ val scheme = uri.scheme ?: return emptySet()
199
+ val host = uri.host ?: return emptySet()
200
+ val port = if (uri.port > 0) ":${uri.port}" else ""
201
+ return setOf("$scheme://$host$port")
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,51 @@
1
+ package com.rubyeverywhere.shell.extensions
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import androidx.fragment.app.Fragment
6
+ import dev.hotwire.core.bridge.BridgeComponent
7
+ import dev.hotwire.core.bridge.BridgeComponentFactory
8
+ import dev.hotwire.navigation.destinations.HotwireDestination
9
+ import kotlin.reflect.KClass
10
+
11
+ // The app-extension registry. This checked-in copy is the empty default so the
12
+ // raw template builds unchanged; `every build` overwrites it — and drops the
13
+ // app repo's native/android/*.kt beside it, in this same package — when
14
+ // config/everywhere.yml declares native code:
15
+ //
16
+ // native:
17
+ // android:
18
+ // components: [ChartComponent] # BridgeComponent subclasses to register
19
+ // screens: # path rules with uri: hotwire://fragment/map
20
+ // map: MapFragment # a Fragment destination
21
+ // splash: LaunchSplash # a View subclass with a (Context) constructor
22
+ //
23
+ // Kotlin sources landing in this directory compile with no build-script change:
24
+ // the source set is the whole directory tree, which is the Gradle equivalent of
25
+ // iOS's filesystem-synchronized Extensions/ folder. Each one must start with
26
+ //
27
+ // package com.rubyeverywhere.shell.extensions
28
+ //
29
+ // so the registry below can name its types — `every build` refuses a file that
30
+ // doesn't, because Kotlin would otherwise fail with "unresolved reference"
31
+ // pointing at this generated file rather than at the app's own source.
32
+ object EverywhereExtensions {
33
+
34
+ /** Extra bridge components, registered alongside the six built-ins. */
35
+ val bridgeComponents: List<BridgeComponentFactory<HotwireDestination, BridgeComponent<HotwireDestination>>> =
36
+ emptyList()
37
+
38
+ /**
39
+ * App-provided native screens. A path rule's `uri` selects one of these by
40
+ * its `@HotwireDestinationDeepLink` annotation; anything unmatched falls
41
+ * through to the web fragment.
42
+ */
43
+ val fragmentDestinations: List<KClass<out Fragment>> = emptyList()
44
+
45
+ /**
46
+ * Replaces the splash overlay's default spinner when non-null. Given the
47
+ * Activity as its context so it can resolve theme attributes — the splash
48
+ * is meant to look like part of the app, not a floating rectangle.
49
+ */
50
+ fun splashView(context: Context): View? = null
51
+ }
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <selector xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <item android:alpha="0.15" android:color="?colorPrimary" />
4
+ </selector>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ The ColorStateList that tints the runtime-rendered tab icons. The bitmap
4
+ IconFont draws is opaque white; every pixel of visible colour on a tab comes
5
+ from here, which is why the glyph follows the theme and the selection state
6
+ for free.
7
+ -->
8
+ <selector xmlns:android="http://schemas.android.com/apk/res/android">
9
+ <item android:state_checked="true" android:color="?colorPrimary" />
10
+ <item android:alpha="0.65" android:color="?colorOnSurface" />
11
+ </selector>
@@ -0,0 +1,26 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ STAMPED: the adaptive icon's foreground layer. The template's placeholder is
4
+ a vector so the raw checkout has no binary blob to keep in sync; `every build`
5
+ replaces it with drawable-<dpi>/ic_launcher_foreground.png rendered from the
6
+ app's icon and deletes this file. Nothing references it by type, only by
7
+ name, so the swap needs no edit to the adaptive-icon XML.
8
+
9
+ Adaptive-icon safe zone: only the centre 66dp of the 108dp canvas is
10
+ guaranteed visible, hence the inset group.
11
+ -->
12
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
13
+ android:width="108dp"
14
+ android:height="108dp"
15
+ android:viewportWidth="108"
16
+ android:viewportHeight="108">
17
+ <group
18
+ android:scaleX="0.5"
19
+ android:scaleY="0.5"
20
+ android:translateX="27"
21
+ android:translateY="27">
22
+ <path
23
+ android:fillColor="#FFFFFF"
24
+ android:pathData="M54,10L98,35L98,73L54,98L10,73L10,35Z" />
25
+ </group>
26
+ </vector>