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,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
|
+
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt
ADDED
|
@@ -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
|
+
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt
ADDED
|
@@ -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,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>
|