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,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"settings": {},
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"patterns": [".*"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"context": "default",
|
|
8
|
+
"pull_to_refresh_enabled": true
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"patterns": ["/new$", "/edit$"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"context": "modal",
|
|
15
|
+
"pull_to_refresh_enabled": false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.content.ActivityNotFoundException
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.pm.ApplicationInfo
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import androidx.activity.ComponentActivity
|
|
9
|
+
import androidx.browser.customtabs.CustomTabsIntent
|
|
10
|
+
import androidx.core.net.toUri
|
|
11
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
12
|
+
import androidx.lifecycle.Lifecycle
|
|
13
|
+
import androidx.lifecycle.LifecycleOwner
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Third-party sign-in (Sign in with Apple / Google / GitHub / anything the app
|
|
17
|
+
* routes through `auth.oauth_paths`), run in a Chrome Custom Tab rather than the
|
|
18
|
+
* app's own web view.
|
|
19
|
+
*
|
|
20
|
+
* Not a stylistic choice: Google rejects OAuth from an embedded WebView outright
|
|
21
|
+
* (`disallowed_useragent`), and the others degrade badly there — no password
|
|
22
|
+
* manager, none of the user's existing provider session, 2FA fighting a keyboard
|
|
23
|
+
* the page doesn't control. A Custom Tab is the user's real browser, sharing its
|
|
24
|
+
* cookie jar, so a signed-in user usually taps once. It is the Android analogue
|
|
25
|
+
* of iOS's `ASWebAuthenticationSession` (support/mobile/ios/App/AuthFlow.swift),
|
|
26
|
+
* and the gem side of the flow is untouched — nothing below knows a provider
|
|
27
|
+
* from a hole in the ground:
|
|
28
|
+
*
|
|
29
|
+
* 1. open /everywhere/auth/start?to=…&return_to=… (in the browser)
|
|
30
|
+
* 2. the app's own OAuth runs and lands somewhere signed in
|
|
31
|
+
* 3. Everywhere::AuthHandoff seals that cookie jar into a one-time token and
|
|
32
|
+
* redirects to <scheme>://auth?token=…
|
|
33
|
+
* 4. that deep link arrives here; MainActivity spends the token at
|
|
34
|
+
* /everywhere/auth/handoff in the web view, which replays the jar there
|
|
35
|
+
*
|
|
36
|
+
* A singleton rather than a per-Activity object because the flow outlives the
|
|
37
|
+
* Activity that started it: the callback is a deep link, and Android is free to
|
|
38
|
+
* destroy and recreate MainActivity while the browser is up.
|
|
39
|
+
*
|
|
40
|
+
* ### Two honest differences from iOS
|
|
41
|
+
*
|
|
42
|
+
* **The callback is weaker.** iOS delivers the redirect *to the session that
|
|
43
|
+
* started it*, so no other app can claim it. A custom scheme on Android is
|
|
44
|
+
* first-come-first-served, and a malicious app registering the same one could
|
|
45
|
+
* receive the token. That is the standard Android OAuth exposure, and the
|
|
46
|
+
* mitigations are the ones the gem already ships: the handoff token is
|
|
47
|
+
* single-use (`AuthToken.decrypt(once: true)`) and short-lived, so a stolen one
|
|
48
|
+
* is a race rather than a durable credential. App Links (`autoVerify`) would
|
|
49
|
+
* close it properly; they can't, because the callback URL shape is shared with
|
|
50
|
+
* iOS.
|
|
51
|
+
*
|
|
52
|
+
* **Cancellation is inferred, not reported.** A Custom Tab hands back no result.
|
|
53
|
+
* Backing out of it simply resumes the Activity, so a resume while a flow is
|
|
54
|
+
* still pending *is* the cancellation — the AppAuth pattern. Same outcome as
|
|
55
|
+
* iOS: the user stays exactly where they started and nothing is shown.
|
|
56
|
+
*/
|
|
57
|
+
object AuthFlow {
|
|
58
|
+
|
|
59
|
+
/** Mirrors iOS's `Result<String, AuthFlow.Failure>`. */
|
|
60
|
+
sealed interface Result {
|
|
61
|
+
data class Success(val token: String) : Result
|
|
62
|
+
|
|
63
|
+
/** The user dismissed the browser. Not an error worth showing them. */
|
|
64
|
+
data object Canceled : Result
|
|
65
|
+
|
|
66
|
+
/** No browser, or a callback that carried nothing to spend. */
|
|
67
|
+
data class Failed(val message: String) : Result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private const val TAG = "EverywhereAuth"
|
|
71
|
+
|
|
72
|
+
private var completion: ((Result) -> Unit)? = null
|
|
73
|
+
private var observer: Watcher? = null
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set by [cancel] and cleared by the next callback or [start]. Without it a
|
|
77
|
+
* flow the app deliberately abandoned — an instance switch, a sign-out from
|
|
78
|
+
* another surface — could still sign the user back in through the orphan
|
|
79
|
+
* path below when its redirect eventually lands.
|
|
80
|
+
*/
|
|
81
|
+
private var abandoned = false
|
|
82
|
+
|
|
83
|
+
val isRunning: Boolean
|
|
84
|
+
get() = completion != null
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Open the provider sign-in in the browser. Nothing is loaded in the web
|
|
88
|
+
* view: the whole point is that this URL never touches it.
|
|
89
|
+
*
|
|
90
|
+
* @param startUrl the gem's /everywhere/auth/start, already carrying `to`
|
|
91
|
+
* and `return_to` (EverywhereConfig.authStartUrl)
|
|
92
|
+
* @param scheme the app's callback scheme, from everywhere.yml `auth.scheme`
|
|
93
|
+
*/
|
|
94
|
+
fun start(
|
|
95
|
+
activity: ComponentActivity,
|
|
96
|
+
startUrl: String,
|
|
97
|
+
scheme: String,
|
|
98
|
+
completion: (Result) -> Unit
|
|
99
|
+
) {
|
|
100
|
+
// A second tap while the browser is up must not restart the flow: the
|
|
101
|
+
// first one is still going to come back through our scheme.
|
|
102
|
+
if (isRunning) return
|
|
103
|
+
|
|
104
|
+
if (scheme.isEmpty()) {
|
|
105
|
+
completion(Result.Failed("no auth scheme is declared in everywhere.yml"))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (activity.isDebuggable) Log.d(TAG, "authFlow: $startUrl")
|
|
110
|
+
|
|
111
|
+
abandoned = false
|
|
112
|
+
this.completion = completion
|
|
113
|
+
watch(activity)
|
|
114
|
+
|
|
115
|
+
val intent = CustomTabsIntent.Builder()
|
|
116
|
+
.setShowTitle(true)
|
|
117
|
+
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
|
118
|
+
.build()
|
|
119
|
+
|
|
120
|
+
// The browser tab must not outlive the flow. Without this the callback
|
|
121
|
+
// deep link brings the app forward but leaves the sign-in page sitting
|
|
122
|
+
// in the task, so the first Back press drops the freshly signed-in user
|
|
123
|
+
// back onto the provider's "you're already signed in" screen.
|
|
124
|
+
intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
intent.launchUrl(activity, startUrl.toUri())
|
|
128
|
+
} catch (e: ActivityNotFoundException) {
|
|
129
|
+
// CustomTabsIntent degrades to a plain ACTION_VIEW when no browser
|
|
130
|
+
// supports Custom Tabs, so reaching here means there is nothing on
|
|
131
|
+
// the device that can open an http URL at all.
|
|
132
|
+
Log.e(TAG, "authFlow: no browser available", e)
|
|
133
|
+
settle(Result.Failed("no browser is available to sign in with"))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Feed every incoming deep link through here. Returns true when the URI was
|
|
139
|
+
* our sign-in callback, so the caller knows not to treat it as a link into
|
|
140
|
+
* app content.
|
|
141
|
+
*/
|
|
142
|
+
fun handleCallback(uri: Uri): Boolean {
|
|
143
|
+
val scheme = EverywhereConfig.shared.authScheme
|
|
144
|
+
if (scheme.isNullOrEmpty() || !uri.scheme.equals(scheme, ignoreCase = true)) return false
|
|
145
|
+
|
|
146
|
+
// Claim it before the resume that follows can read the pending flow as
|
|
147
|
+
// a cancellation.
|
|
148
|
+
val pending = completion
|
|
149
|
+
stopWatching()
|
|
150
|
+
completion = null
|
|
151
|
+
|
|
152
|
+
// The host is `auth` today (AuthHandoff#close), but iOS accepts any host
|
|
153
|
+
// its scheme returns through, and so do we — the token is the contract.
|
|
154
|
+
val token = runCatching { uri.getQueryParameter("token") }.getOrNull()
|
|
155
|
+
if (token.isNullOrBlank()) {
|
|
156
|
+
Log.w(TAG, "authFlow: the callback carried no token")
|
|
157
|
+
abandoned = false
|
|
158
|
+
pending?.invoke(Result.Failed("the sign-in callback carried no token"))
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (pending != null) {
|
|
163
|
+
abandoned = false
|
|
164
|
+
pending(Result.Success(token))
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Orphaned: the Activity that started the flow was destroyed (a rotation
|
|
169
|
+
// while the browser was up), or the callback cold-launched the process.
|
|
170
|
+
// The token is still perfectly good and the user still expects to end up
|
|
171
|
+
// signed in, so spend it through the normal visit path. What is lost
|
|
172
|
+
// versus the live path is only the immediate config refetch — the
|
|
173
|
+
// auth-gated tabs catch up on the next foreground.
|
|
174
|
+
if (abandoned) {
|
|
175
|
+
abandoned = false
|
|
176
|
+
Log.i(TAG, "authFlow: ignoring a callback for an abandoned flow")
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Log.i(TAG, "authFlow: completing an orphaned callback through a native visit")
|
|
181
|
+
EverywhereEvents.emit(
|
|
182
|
+
EverywhereEvents.Event.NativeVisit(EverywhereConfig.shared.authHandoffUrl(token))
|
|
183
|
+
)
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Abandon a flow that's still out — used when the app resets underneath it
|
|
189
|
+
* (an instance switch, a sign-out from another surface), where whatever it
|
|
190
|
+
* returns would be answering a question the app no longer has.
|
|
191
|
+
*
|
|
192
|
+
* Unlike iOS this cannot close the browser: a Custom Tab belongs to the
|
|
193
|
+
* browser, not to us. It stops us acting on the return, and stops the
|
|
194
|
+
* resume that follows from being reported as a cancellation.
|
|
195
|
+
*/
|
|
196
|
+
fun cancel() {
|
|
197
|
+
if (!isRunning) return
|
|
198
|
+
abandoned = true
|
|
199
|
+
stopWatching()
|
|
200
|
+
completion = null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ------------------------------------------------------------------------
|
|
204
|
+
// Cancellation detection
|
|
205
|
+
// ------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
private fun watch(activity: ComponentActivity) {
|
|
208
|
+
stopWatching()
|
|
209
|
+
Watcher(activity).also {
|
|
210
|
+
observer = it
|
|
211
|
+
activity.lifecycle.addObserver(it)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fun stopWatching() {
|
|
216
|
+
observer?.detach()
|
|
217
|
+
observer = null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun settle(result: Result) {
|
|
221
|
+
val pending = completion ?: return
|
|
222
|
+
stopWatching()
|
|
223
|
+
completion = null
|
|
224
|
+
pending(result)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Watches the Activity that launched the browser. `leftForBrowser` is what
|
|
229
|
+
* makes this reliable: [start] runs while the Activity is resumed, so
|
|
230
|
+
* without it the very next resume — the one that follows our own launch —
|
|
231
|
+
* would read as a cancellation.
|
|
232
|
+
*/
|
|
233
|
+
private class Watcher(activity: ComponentActivity) : DefaultLifecycleObserver {
|
|
234
|
+
private var lifecycle: Lifecycle? = activity.lifecycle
|
|
235
|
+
private var leftForBrowser = false
|
|
236
|
+
|
|
237
|
+
fun detach() {
|
|
238
|
+
lifecycle?.removeObserver(this)
|
|
239
|
+
lifecycle = null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
override fun onPause(owner: LifecycleOwner) {
|
|
243
|
+
leftForBrowser = true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
override fun onResume(owner: LifecycleOwner) {
|
|
247
|
+
if (!leftForBrowser) return
|
|
248
|
+
AuthFlow.settle(Result.Canceled)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The Activity is gone (a rotation while the browser was up), so its
|
|
253
|
+
* completion closes over a destroyed screen and must not be called.
|
|
254
|
+
* The flow itself isn't over — the redirect is still coming — and
|
|
255
|
+
* [handleCallback]'s orphan path is what finishes it.
|
|
256
|
+
*/
|
|
257
|
+
override fun onDestroy(owner: LifecycleOwner) {
|
|
258
|
+
detach()
|
|
259
|
+
if (AuthFlow.observer === this) {
|
|
260
|
+
AuthFlow.observer = null
|
|
261
|
+
AuthFlow.completion = null
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private val ComponentActivity.isDebuggable: Boolean
|
|
267
|
+
get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
|
268
|
+
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.webkit.CookieManager
|
|
5
|
+
import androidx.core.net.toUri
|
|
6
|
+
import com.rubyeverywhere.shell.bridge.BiometricsComponent
|
|
7
|
+
import com.rubyeverywhere.shell.bridge.HapticsComponent
|
|
8
|
+
import com.rubyeverywhere.shell.bridge.MenuComponent
|
|
9
|
+
import com.rubyeverywhere.shell.bridge.NotificationComponent
|
|
10
|
+
import com.rubyeverywhere.shell.bridge.PermissionsComponent
|
|
11
|
+
import com.rubyeverywhere.shell.bridge.StorageComponent
|
|
12
|
+
import com.rubyeverywhere.shell.bridge.WebControlChannel
|
|
13
|
+
import com.rubyeverywhere.shell.extensions.EverywhereExtensions
|
|
14
|
+
import dev.hotwire.core.bridge.BridgeComponentFactory
|
|
15
|
+
import dev.hotwire.core.bridge.KotlinXJsonConverter
|
|
16
|
+
import dev.hotwire.core.config.Hotwire
|
|
17
|
+
import dev.hotwire.core.logging.HotwireLogLevel
|
|
18
|
+
import dev.hotwire.core.turbo.config.PathConfiguration
|
|
19
|
+
import dev.hotwire.core.turbo.visit.VisitProposal
|
|
20
|
+
import dev.hotwire.core.turbo.webview.HotwireWebView
|
|
21
|
+
import dev.hotwire.navigation.activities.HotwireActivity
|
|
22
|
+
import dev.hotwire.navigation.config.defaultFragmentDestination
|
|
23
|
+
import dev.hotwire.navigation.config.registerBridgeComponents
|
|
24
|
+
import dev.hotwire.navigation.config.registerFragmentDestinations
|
|
25
|
+
import dev.hotwire.navigation.config.registerRouteDecisionHandlers
|
|
26
|
+
import dev.hotwire.navigation.navigator.NavigatorConfiguration
|
|
27
|
+
import dev.hotwire.navigation.routing.AppNavigationRouteDecisionHandler
|
|
28
|
+
import dev.hotwire.navigation.routing.BrowserTabRouteDecisionHandler
|
|
29
|
+
import dev.hotwire.navigation.routing.Router
|
|
30
|
+
import dev.hotwire.navigation.routing.SystemNavigationRouteDecisionHandler
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Everything that must be true before the first Activity exists: the parsed
|
|
34
|
+
* config, the framework's global configuration, the bridge, and the first read
|
|
35
|
+
* of the path configuration.
|
|
36
|
+
*
|
|
37
|
+
* The Android counterpart of iOS's AppDelegate.configureHotwire, with one
|
|
38
|
+
* difference worth stating: on Android the path configuration is loaded here
|
|
39
|
+
* rather than from the Activity, because `MainActivity.navigatorConfigurations()`
|
|
40
|
+
* is read during its `super.onCreate` and needs the tabs to already exist. The
|
|
41
|
+
* bundled copy loads synchronously, so they do.
|
|
42
|
+
*/
|
|
43
|
+
class EverywhereApplication : Application() {
|
|
44
|
+
|
|
45
|
+
override fun onCreate() {
|
|
46
|
+
super.onCreate()
|
|
47
|
+
|
|
48
|
+
EverywhereConfig.initialize(this)
|
|
49
|
+
configureHotwire()
|
|
50
|
+
loadPathConfiguration()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun configureHotwire() {
|
|
54
|
+
val config = EverywhereConfig.shared
|
|
55
|
+
|
|
56
|
+
Hotwire.defaultFragmentDestination = WebFragment::class
|
|
57
|
+
|
|
58
|
+
Hotwire.registerFragmentDestinations(
|
|
59
|
+
WebFragment::class,
|
|
60
|
+
WebBottomSheetFragment::class,
|
|
61
|
+
// App-provided native screens (everywhere.yml native.android.screens).
|
|
62
|
+
*EverywhereExtensions.fragmentDestinations.toTypedArray()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
Hotwire.registerBridgeComponents(
|
|
66
|
+
BridgeComponentFactory("everywhere--notification", ::NotificationComponent),
|
|
67
|
+
BridgeComponentFactory("everywhere--haptics", ::HapticsComponent),
|
|
68
|
+
BridgeComponentFactory("everywhere--permissions", ::PermissionsComponent),
|
|
69
|
+
BridgeComponentFactory("everywhere--biometrics", ::BiometricsComponent),
|
|
70
|
+
BridgeComponentFactory("everywhere--storage", ::StorageComponent),
|
|
71
|
+
BridgeComponentFactory("everywhere--menu", ::MenuComponent),
|
|
72
|
+
*EverywhereExtensions.bridgeComponents.toTypedArray()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Order matters: ours run before the framework's, and the framework's
|
|
76
|
+
// first handler claims every same-host URL. A reset or a provider
|
|
77
|
+
// sign-in has to be recognised before that happens.
|
|
78
|
+
Hotwire.registerRouteDecisionHandlers(
|
|
79
|
+
ResetRouteDecisionHandler(),
|
|
80
|
+
AuthFlowRouteDecisionHandler(),
|
|
81
|
+
AppNavigationRouteDecisionHandler(),
|
|
82
|
+
BrowserTabRouteDecisionHandler(),
|
|
83
|
+
SystemNavigationRouteDecisionHandler()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
Hotwire.config.jsonConverter = KotlinXJsonConverter()
|
|
87
|
+
Hotwire.config.applicationUserAgentPrefix = config.userAgentPrefix
|
|
88
|
+
Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG
|
|
89
|
+
Hotwire.config.logger.logLevel =
|
|
90
|
+
if (BuildConfig.DEBUG) HotwireLogLevel.DEBUG else HotwireLogLevel.NONE
|
|
91
|
+
|
|
92
|
+
// Android WebView has no user-script API, so the two things iOS does
|
|
93
|
+
// with WKUserContentController — expose the control channel, and inject
|
|
94
|
+
// window.__EVERYWHERE_CONFIG__ at document start — are both done inside
|
|
95
|
+
// WebControlChannel.install, through WebViewCompat, per web view.
|
|
96
|
+
//
|
|
97
|
+
// Per web view, not once at launch, because webConfigJson carries the
|
|
98
|
+
// instance override, and that changes when a picker re-roots the app.
|
|
99
|
+
Hotwire.config.makeCustomWebView = { context ->
|
|
100
|
+
HotwireWebView(context, null).also { webView ->
|
|
101
|
+
WebControlChannel.install(webView, config.webConfigJson)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Both sources at launch: the bundled copy loads synchronously (instant
|
|
108
|
+
* tabs, works offline) and the server copy refines it. The server copy is
|
|
109
|
+
* the auth-gated one, so it is fetched with the WebView's cookies — see
|
|
110
|
+
* [MainActivity.cookieHeaders] for why that is not automatic.
|
|
111
|
+
*/
|
|
112
|
+
private fun loadPathConfiguration() {
|
|
113
|
+
val config = EverywhereConfig.shared
|
|
114
|
+
val remoteUrl = config.pathConfigurationUrl
|
|
115
|
+
val cookie = runCatching { CookieManager.getInstance().getCookie(remoteUrl) }.getOrNull()
|
|
116
|
+
|
|
117
|
+
Hotwire.loadPathConfiguration(
|
|
118
|
+
context = this,
|
|
119
|
+
location = PathConfiguration.Location(
|
|
120
|
+
assetFilePath = "json/path-configuration.json",
|
|
121
|
+
remoteFileUrl = remoteUrl
|
|
122
|
+
),
|
|
123
|
+
options = PathConfiguration.LoaderOptions(
|
|
124
|
+
httpHeaders = if (cookie.isNullOrEmpty()) emptyMap() else mapOf("Cookie" to cookie)
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Intercepts `/everywhere/reset` — the URL the gem redirects to after a sign-in
|
|
132
|
+
* or sign-out — natively, so resetting never depends on the reset page's
|
|
133
|
+
* JavaScript running. Cancelling the visit leaves the reset to [MainActivity],
|
|
134
|
+
* which does the whole sequence deterministically.
|
|
135
|
+
*/
|
|
136
|
+
private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
137
|
+
override val name = "everywhere-reset"
|
|
138
|
+
|
|
139
|
+
override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean =
|
|
140
|
+
proposal.location.toUri().path == RESET_PATH
|
|
141
|
+
|
|
142
|
+
override fun handle(
|
|
143
|
+
proposal: VisitProposal,
|
|
144
|
+
configuration: NavigatorConfiguration,
|
|
145
|
+
activity: HotwireActivity
|
|
146
|
+
): Router.Decision {
|
|
147
|
+
val to = proposal.location.toUri().getQueryParameter("to")
|
|
148
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(to))
|
|
149
|
+
return Router.Decision.CANCEL
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private companion object {
|
|
153
|
+
const val RESET_PATH = "/everywhere/reset"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A provider sign-in: hand it to a Custom Tab instead of loading it in the web
|
|
159
|
+
* view. Cancelling leaves the user on the page they tapped from, which is where
|
|
160
|
+
* they belong if they close the tab.
|
|
161
|
+
*/
|
|
162
|
+
private class AuthFlowRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
163
|
+
override val name = "everywhere-auth"
|
|
164
|
+
|
|
165
|
+
override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean =
|
|
166
|
+
EverywhereConfig.shared.authTarget(proposal.location) != null
|
|
167
|
+
|
|
168
|
+
override fun handle(
|
|
169
|
+
proposal: VisitProposal,
|
|
170
|
+
configuration: NavigatorConfiguration,
|
|
171
|
+
activity: HotwireActivity
|
|
172
|
+
): Router.Decision {
|
|
173
|
+
EverywhereConfig.shared.authTarget(proposal.location)?.let {
|
|
174
|
+
EverywhereEvents.emit(EverywhereEvents.Event.AuthFlow(it))
|
|
175
|
+
}
|
|
176
|
+
return Router.Decision.CANCEL
|
|
177
|
+
}
|
|
178
|
+
}
|