ruby_everywhere 0.8.0 → 0.9.1
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/lib/everywhere/builders/android.rb +39 -0
- data/lib/everywhere/config/app.rb +12 -0
- data/lib/everywhere/config/mobile.rb +13 -1
- data/lib/everywhere/config/shell.rb +1 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +77 -5
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +25 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +193 -39
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt +105 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +88 -4
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +6 -10
- data/support/mobile/android/app/src/main/res/layout/everywhere_error.xml +60 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +19 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +4 -0
- data/support/mobile/ios/App/ErrorViewController.swift +21 -3
- data/support/mobile/ios/App/EverywhereConfig.swift +5 -0
- data/support/mobile/ios/App/SceneDelegate.swift +73 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d36d2151f58701a6eab470774617b2c81dd99b78caa9829a152017e9840652e9
|
|
4
|
+
data.tar.gz: 1315f5032b754d7a45418604850cd96f9168ddeb25a7faf24df91608df7ede8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2a6201f5ecae1781fecca6750a8952250d78e22e8b45c224c696fab8717a4ff2bb173b591d1e8724d69c695633358a8fb5320e74e2e849fcab738d5f2870b68
|
|
7
|
+
data.tar.gz: acaed9ef6364a807dcdf85378e33bac32f99e5309be5e9fcc404c257f6c2dc348f5f61b5e2482582a4bfca56495677da97dcf9de51f308ef9522b8919854185f
|
|
@@ -287,10 +287,49 @@ module Everywhere
|
|
|
287
287
|
write_drawables(work)
|
|
288
288
|
write_icon_font(work)
|
|
289
289
|
write_manifest(work)
|
|
290
|
+
write_network_security(work)
|
|
290
291
|
write_extensions(work)
|
|
291
292
|
write_native_packages(work)
|
|
292
293
|
end
|
|
293
294
|
|
|
295
|
+
# The template's baseline forbids cleartext everywhere (debug overlays
|
|
296
|
+
# loopback + .local only — see the two res/xml/network_security_config.xml
|
|
297
|
+
# files and their comments). An instance app (`remote.instances: true`)
|
|
298
|
+
# exists to load OTHER apps, and `every preview --lan` serves those as
|
|
299
|
+
# plain http on whatever LAN address the laptop has that day — an address
|
|
300
|
+
# no config written at build time can enumerate, and Android's
|
|
301
|
+
# network-security-config has no CIDR syntax to scope a private-range
|
|
302
|
+
# exception the way iOS's NSAllowsLocalNetworking does. So instance apps
|
|
303
|
+
# get cleartext permitted wholesale, in both build types (the debug
|
|
304
|
+
# overlay REPLACES main's resource, so leaving it standing would make
|
|
305
|
+
# debug builds stricter than release). The shell's own traffic to
|
|
306
|
+
# remote.url stays https; the alternative is a picker that cannot open
|
|
307
|
+
# what it picked.
|
|
308
|
+
#
|
|
309
|
+
# Written only when instances is on: stage() re-copies the template over
|
|
310
|
+
# the work dir every build, so switching it off restores the strict
|
|
311
|
+
# default without a writer for that side of the toggle.
|
|
312
|
+
def write_network_security(work)
|
|
313
|
+
return unless @config.remote_instances?
|
|
314
|
+
|
|
315
|
+
UI.step("allowing cleartext http #{UI.dim("(remote.instances: previewed apps live on LAN addresses)")}")
|
|
316
|
+
xml = <<~XML
|
|
317
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
318
|
+
<!-- Written by `every build` because config/everywhere.yml sets
|
|
319
|
+
remote.instances: true — previewed apps arrive as plain http on
|
|
320
|
+
LAN addresses that cannot be enumerated at build time. Do not
|
|
321
|
+
edit; the template's strict default returns if instances is
|
|
322
|
+
turned off. -->
|
|
323
|
+
<network-security-config>
|
|
324
|
+
<base-config cleartextTrafficPermitted="true" />
|
|
325
|
+
</network-security-config>
|
|
326
|
+
XML
|
|
327
|
+
[File.join(work, "app", "src", "main", "res", "xml", "network_security_config.xml"),
|
|
328
|
+
File.join(work, "app", "src", "debug", "res", "xml", "network_security_config.xml")].each do |path|
|
|
329
|
+
File.write(path, xml)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
294
333
|
# --- identity -------------------------------------------------------------
|
|
295
334
|
|
|
296
335
|
# app/build.gradle.kts reads these four values, so identity is stamped
|
|
@@ -114,6 +114,18 @@ module Everywhere
|
|
|
114
114
|
@data.dig("remote", "instances") == true
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Whether the shell offers its built-in escape hatch while re-rooted on
|
|
118
|
+
# an instance: a "back to start" toolbar action on screens where the tab
|
|
119
|
+
# bar (and the injected leave tab it carries) is hidden — every
|
|
120
|
+
# modal-context screen, and an instance serving no tabs at all. On by
|
|
121
|
+
# default because stranding is inherent to the picker pattern (a
|
|
122
|
+
# previewed app's whole signed-out surface can be modal); an instance
|
|
123
|
+
# app that wants no shell chrome opts out with
|
|
124
|
+
# `remote: { instances_escape: false }`.
|
|
125
|
+
def remote_instances_escape?
|
|
126
|
+
remote_instances? && @data.dig("remote", "instances_escape") != false
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
def tint_color
|
|
118
130
|
normalize_color(appearance["tint_color"])
|
|
119
131
|
end
|
|
@@ -182,12 +182,24 @@ module Everywhere
|
|
|
182
182
|
# `uri` pointing at a Fragment this build doesn't contain resolves to
|
|
183
183
|
# nothing and the visit dead-ends. An explicit `uri:` always wins — that's
|
|
184
184
|
# the escape hatch for a screen whose annotation says something else.
|
|
185
|
+
#
|
|
186
|
+
# `fallback_uri` rides along for the shell that ISN'T this app's own
|
|
187
|
+
# build: a Jump-style instance shell fetches this same document live, and
|
|
188
|
+
# the derived uri names a Fragment compiled into the app's build, not the
|
|
189
|
+
# picker's. Hotwire falls back per rule when the primary uri resolves to
|
|
190
|
+
# no registered destination, and every gem-built shell registers the
|
|
191
|
+
# missing-screen placeholder at this address — so the previewing shell
|
|
192
|
+
# says "this screen is native" instead of spinning forever, while the
|
|
193
|
+
# app's own build never consults the fallback at all.
|
|
194
|
+
MISSING_SCREEN_URI = "hotwire://fragment/everywhere-missing"
|
|
195
|
+
|
|
185
196
|
def android_screen_properties(properties)
|
|
186
197
|
id = properties["view_controller"]
|
|
187
198
|
return properties if id.nil? || properties.key?("uri")
|
|
188
199
|
return properties unless native_android_screens.key?(id.to_s)
|
|
189
200
|
|
|
190
|
-
properties.
|
|
201
|
+
fallback = properties.key?("fallback_uri") ? {} : { "fallback_uri" => MISSING_SCREEN_URI }
|
|
202
|
+
properties.merge("uri" => "hotwire://fragment/#{id}", **fallback)
|
|
191
203
|
end
|
|
192
204
|
|
|
193
205
|
# The full path-configuration document for one mobile platform — rules plus
|
|
@@ -31,6 +31,7 @@ module Everywhere
|
|
|
31
31
|
"mode" => mode,
|
|
32
32
|
"remote_url" => remote_url,
|
|
33
33
|
"remote_instances" => (true if remote_instances?),
|
|
34
|
+
"instances_escape" => (true if remote_instances_escape?),
|
|
34
35
|
"entry_path" => entry_path,
|
|
35
36
|
"tint_color" => tint_color,
|
|
36
37
|
"background_color" => background_color,
|
data/lib/everywhere/version.rb
CHANGED
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt
CHANGED
|
@@ -2,6 +2,8 @@ package com.rubyeverywhere.shell
|
|
|
2
2
|
|
|
3
3
|
import android.app.Application
|
|
4
4
|
import android.webkit.CookieManager
|
|
5
|
+
import android.webkit.WebSettings
|
|
6
|
+
import android.widget.Toast
|
|
5
7
|
import androidx.core.net.toUri
|
|
6
8
|
import com.rubyeverywhere.shell.bridge.BiometricsComponent
|
|
7
9
|
import com.rubyeverywhere.shell.bridge.HapticsComponent
|
|
@@ -20,6 +22,7 @@ import dev.hotwire.core.turbo.visit.VisitProposal
|
|
|
20
22
|
import dev.hotwire.core.turbo.webview.HotwireWebView
|
|
21
23
|
import dev.hotwire.navigation.activities.HotwireActivity
|
|
22
24
|
import dev.hotwire.navigation.config.defaultFragmentDestination
|
|
25
|
+
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
23
26
|
import dev.hotwire.navigation.config.registerBridgeComponents
|
|
24
27
|
import dev.hotwire.navigation.config.registerFragmentDestinations
|
|
25
28
|
import dev.hotwire.navigation.config.registerRouteDecisionHandlers
|
|
@@ -55,12 +58,22 @@ class EverywhereApplication : Application() {
|
|
|
55
58
|
|
|
56
59
|
Hotwire.defaultFragmentDestination = WebFragment::class
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
val fragmentDestinations = listOf(
|
|
59
62
|
WebFragment::class,
|
|
60
63
|
WebBottomSheetFragment::class,
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
// Where a rule's fallback_uri lands when its primary uri names a
|
|
65
|
+
// Fragment this build doesn't contain (previewing another app).
|
|
66
|
+
MissingExtensionFragment::class
|
|
67
|
+
) + EverywhereExtensions.fragmentDestinations // everywhere.yml native.android.screens
|
|
68
|
+
Hotwire.registerFragmentDestinations(*fragmentDestinations.toTypedArray())
|
|
69
|
+
|
|
70
|
+
// Every uri those destinations answer to, read from the same
|
|
71
|
+
// annotation Hotwire itself matches against — handed to the
|
|
72
|
+
// missing-screen guard below so it can tell a resolvable rule from
|
|
73
|
+
// one that would go nowhere.
|
|
74
|
+
val registeredUris = fragmentDestinations
|
|
75
|
+
.mapNotNull { it.java.getAnnotation(HotwireDestinationDeepLink::class.java)?.uri }
|
|
76
|
+
.toSet()
|
|
64
77
|
|
|
65
78
|
Hotwire.registerBridgeComponents(
|
|
66
79
|
BridgeComponentFactory("everywhere--notification", ::NotificationComponent),
|
|
@@ -78,6 +91,7 @@ class EverywhereApplication : Application() {
|
|
|
78
91
|
Hotwire.registerRouteDecisionHandlers(
|
|
79
92
|
ResetRouteDecisionHandler(),
|
|
80
93
|
AuthFlowRouteDecisionHandler(),
|
|
94
|
+
MissingScreenRouteDecisionHandler(registeredUris),
|
|
81
95
|
AppNavigationRouteDecisionHandler(),
|
|
82
96
|
BrowserTabRouteDecisionHandler(),
|
|
83
97
|
SystemNavigationRouteDecisionHandler()
|
|
@@ -99,6 +113,16 @@ class EverywhereApplication : Application() {
|
|
|
99
113
|
Hotwire.config.makeCustomWebView = { context ->
|
|
100
114
|
HotwireWebView(context, null).also { webView ->
|
|
101
115
|
WebControlChannel.install(webView, config.webConfigJson)
|
|
116
|
+
// Instance apps pair with LAN-hosted previews: the picker page
|
|
117
|
+
// is https but the app it probes is plain http, which WebView
|
|
118
|
+
// blocks as mixed content — and then the picker reports a live
|
|
119
|
+
// preview as "not answering". Scoped to remote.instances so
|
|
120
|
+
// ordinary apps keep the strict default. The main-frame http
|
|
121
|
+
// load itself is governed by network_security_config.xml,
|
|
122
|
+
// which `every build` opens up under the same flag.
|
|
123
|
+
if (config.remoteInstances) {
|
|
124
|
+
webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
125
|
+
}
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
}
|
|
@@ -145,7 +169,18 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
|
145
169
|
activity: HotwireActivity
|
|
146
170
|
): Router.Decision {
|
|
147
171
|
val to = proposal.location.toUri().getQueryParameter("to")
|
|
148
|
-
EverywhereEvents
|
|
172
|
+
// Directly, not through EverywhereEvents: the event collector is a
|
|
173
|
+
// coroutine that can lose the race to the tab-path watcher when a
|
|
174
|
+
// sign-in changes the auth-gated tabs, and the watcher's plain
|
|
175
|
+
// recreate() restores the half-dismissed pre-auth back stack — a
|
|
176
|
+
// framework crash. Calling in synchronously puts the reset (and its
|
|
177
|
+
// pendingResetTarget) in place before the config refresh can land.
|
|
178
|
+
val main = activity as? MainActivity
|
|
179
|
+
if (main != null) {
|
|
180
|
+
main.resetAppNow(to)
|
|
181
|
+
} else {
|
|
182
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(to))
|
|
183
|
+
}
|
|
149
184
|
return Router.Decision.CANCEL
|
|
150
185
|
}
|
|
151
186
|
|
|
@@ -154,6 +189,43 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
|
154
189
|
}
|
|
155
190
|
}
|
|
156
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The last line of defence for a native-screen rule this build can't serve.
|
|
194
|
+
*
|
|
195
|
+
* A previewed app on a current gem serves `fallback_uri` pointing at
|
|
196
|
+
* [MissingExtensionFragment], and the visit lands there — this handler never
|
|
197
|
+
* matches. An app on an older gem serves only `uri: hotwire://fragment/<id>`,
|
|
198
|
+
* and Hotwire's answer to a uri no registered destination claims is to do
|
|
199
|
+
* nothing at all — which reads as a spinner that never ends. Refuse the visit
|
|
200
|
+
* loudly instead: a toast naming the screen, and the user stays on the page
|
|
201
|
+
* they tapped from.
|
|
202
|
+
*/
|
|
203
|
+
private class MissingScreenRouteDecisionHandler(
|
|
204
|
+
private val registeredUris: Set<String>
|
|
205
|
+
) : Router.RouteDecisionHandler {
|
|
206
|
+
override val name = "everywhere-missing-screen"
|
|
207
|
+
|
|
208
|
+
override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean {
|
|
209
|
+
val uri = proposal.properties["uri"]?.toString() ?: return false
|
|
210
|
+
val fallback = proposal.properties["fallback_uri"]?.toString()
|
|
211
|
+
return uri !in registeredUris && (fallback == null || fallback !in registeredUris)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override fun handle(
|
|
215
|
+
proposal: VisitProposal,
|
|
216
|
+
configuration: NavigatorConfiguration,
|
|
217
|
+
activity: HotwireActivity
|
|
218
|
+
): Router.Decision {
|
|
219
|
+
val identifier = proposal.properties["view_controller"]?.toString() ?: "native"
|
|
220
|
+
Toast.makeText(
|
|
221
|
+
activity,
|
|
222
|
+
activity.getString(R.string.everywhere_missing_screen_toast, identifier),
|
|
223
|
+
Toast.LENGTH_LONG
|
|
224
|
+
).show()
|
|
225
|
+
return Router.Decision.CANCEL
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
157
229
|
/**
|
|
158
230
|
* A provider sign-in: hand it to a Custom Tab instead of loading it in the web
|
|
159
231
|
* view. Cancelling leaves the user on the page they tapped from, which is where
|
|
@@ -114,6 +114,10 @@ class EverywhereConfig private constructor(
|
|
|
114
114
|
val version: String? = json.string("version")
|
|
115
115
|
val entryPath: String? = json.string("entry_path")
|
|
116
116
|
val remoteInstances: Boolean = json["remote_instances"]?.jsonPrimitive?.booleanOrNull == true
|
|
117
|
+
|
|
118
|
+
/** `remote.instances_escape` — the built-in "back to start" toolbar action
|
|
119
|
+
* offered while re-rooted, on screens whose tab bar is hidden. */
|
|
120
|
+
val instancesEscape: Boolean = json["instances_escape"]?.jsonPrimitive?.booleanOrNull == true
|
|
117
121
|
val permissions: List<String> =
|
|
118
122
|
json["permissions"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
|
119
123
|
val universalLinkHosts: List<String> =
|
|
@@ -189,6 +193,27 @@ class EverywhereConfig private constructor(
|
|
|
189
193
|
return if (path.isNullOrEmpty() || path == "/") rootUrl else url(forPath = path)
|
|
190
194
|
}
|
|
191
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Every origin the shell's own pages can be served from: the stamped
|
|
198
|
+
* root, the dev override, and a picked instance. The control channel is
|
|
199
|
+
* offered to all of them, not just the current [rootUrl] — while rooted
|
|
200
|
+
* on an instance, the picker's pages still come from the stamped origin,
|
|
201
|
+
* and without the channel there they could switch in but never switch
|
|
202
|
+
* again (`Everywhere.instance.*` would fall back to a plain navigation).
|
|
203
|
+
*/
|
|
204
|
+
val trustedRootOrigins: Set<String>
|
|
205
|
+
get() = listOfNotNull(stampedRemoteUrl, devUrl, instanceUrl)
|
|
206
|
+
.mapNotNull(::originOf)
|
|
207
|
+
.toSet()
|
|
208
|
+
|
|
209
|
+
private fun originOf(url: String): String? {
|
|
210
|
+
val uri = url.toUri()
|
|
211
|
+
val scheme = uri.scheme ?: return null
|
|
212
|
+
val host = uri.host ?: return null
|
|
213
|
+
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
214
|
+
return "$scheme://$host$port"
|
|
215
|
+
}
|
|
216
|
+
|
|
192
217
|
/**
|
|
193
218
|
* The server half of the path configuration (rules + settings.tabs),
|
|
194
219
|
* generated from everywhere.yml by the gem's MobileConfigEndpoint — so tab
|
|
@@ -130,6 +130,22 @@ class MainActivity : HotwireActivity() {
|
|
|
130
130
|
/** The open More sheet, so a second tap doesn't stack a second one. */
|
|
131
131
|
private var moreSheet: BottomSheetDialog? = null
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Single-flight guard for `recreate()`: a reset and the tab-path watcher
|
|
135
|
+
* can both want a rebuild within the same beat (a sign-in changes the
|
|
136
|
+
* auth-gated tabs moments after the reset route fires), and two stacked
|
|
137
|
+
* relaunches with navigation mid-teardown is a framework crash. Dies with
|
|
138
|
+
* the Activity, which is exactly the right lifetime.
|
|
139
|
+
*/
|
|
140
|
+
private var rebuildScheduled = false
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Where the in-flight reset is headed, so a tab-path change caused BY that
|
|
144
|
+
* reset rebuilds fresh (EXTRA_ROUTE, no state restoration) instead of
|
|
145
|
+
* restoring the pre-auth back stack. Null when no reset is in flight.
|
|
146
|
+
*/
|
|
147
|
+
private var pendingResetTarget: String? = null
|
|
148
|
+
|
|
133
149
|
// ------------------------------------------------------------------------
|
|
134
150
|
// Lifecycle
|
|
135
151
|
// ------------------------------------------------------------------------
|
|
@@ -140,7 +156,16 @@ class MainActivity : HotwireActivity() {
|
|
|
140
156
|
// Swap to the real theme before the content view inflates.
|
|
141
157
|
setTheme(R.style.Theme_Everywhere)
|
|
142
158
|
enableEdgeToEdge()
|
|
143
|
-
|
|
159
|
+
|
|
160
|
+
// A rebuild ([rebuildAndRoute]: reset, sign-in/out, instance switch)
|
|
161
|
+
// arrives with EXTRA_ROUTE on the Intent and must start from scratch:
|
|
162
|
+
// handing the saved state to super restores every navigator's back
|
|
163
|
+
// stack, climbing the page the user just left back on top of the new
|
|
164
|
+
// root. Dropped here, the recreate behaves like a fresh launch — while
|
|
165
|
+
// a real config change or process death (no EXTRA_ROUTE) restores
|
|
166
|
+
// normally.
|
|
167
|
+
val state = if (intent.hasExtra(EXTRA_ROUTE)) null else savedInstanceState
|
|
168
|
+
super.onCreate(state)
|
|
144
169
|
|
|
145
170
|
setContentView(R.layout.activity_main)
|
|
146
171
|
findViewById<View>(R.id.root).applyDefaultImeWindowInsets()
|
|
@@ -148,11 +173,18 @@ class MainActivity : HotwireActivity() {
|
|
|
148
173
|
splash = findViewById(R.id.splash)
|
|
149
174
|
showSplash()
|
|
150
175
|
|
|
151
|
-
initializeTabs(
|
|
176
|
+
initializeTabs(state?.getInt(STATE_SELECTED_TAB) ?: 0)
|
|
152
177
|
|
|
153
178
|
observeEvents()
|
|
154
179
|
observePathConfiguration()
|
|
155
180
|
|
|
181
|
+
// A rebuild chain in progress (see rebuildAndRoute): the config that
|
|
182
|
+
// motivated it may land after this Activity built its tabs, and the
|
|
183
|
+
// paths watcher needs to know to rebuild fresh again, aimed at the
|
|
184
|
+
// same landing page. Cleared by applyPathConfiguration once a config
|
|
185
|
+
// pass finds the tabs already correct.
|
|
186
|
+
pendingResetTarget = intent.getStringExtra(EXTRA_RESET_TARGET)
|
|
187
|
+
|
|
156
188
|
// Survives `recreate()` — the Intent does, fields don't. See
|
|
157
189
|
// [rebuildAndRoute].
|
|
158
190
|
intent.getStringExtra(EXTRA_ROUTE)?.let {
|
|
@@ -203,6 +235,13 @@ class MainActivity : HotwireActivity() {
|
|
|
203
235
|
|
|
204
236
|
override fun navigatorConfigurations(): List<NavigatorConfiguration> = pooledConfigurations
|
|
205
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Whether a tab bar is on screen at all — [WebFragment] uses this to
|
|
240
|
+
* decide if the instance-escape toolbar action is needed. Modal screens
|
|
241
|
+
* hide the bar regardless; the fragment checks its own context for that.
|
|
242
|
+
*/
|
|
243
|
+
val hasTabBar: Boolean get() = currentArrangement.slots.isNotEmpty()
|
|
244
|
+
|
|
206
245
|
/**
|
|
207
246
|
* A host finished building its start destination. Used to flush a route
|
|
208
247
|
* captured before any navigator existed — at `onCreate` there is nothing to
|
|
@@ -220,7 +259,14 @@ class MainActivity : HotwireActivity() {
|
|
|
220
259
|
|
|
221
260
|
pendingRoute = null
|
|
222
261
|
pendingRouteHostId = null
|
|
223
|
-
|
|
262
|
+
// Post, don't call: this callback fires from onAttachFragment while
|
|
263
|
+
// the host's start fragment is still mid-transaction — no view, no
|
|
264
|
+
// web delegate — and routing into it synchronously crashes Hotwire
|
|
265
|
+
// ("lateinit property webDelegate has not been initialized"). One
|
|
266
|
+
// trip through the main queue lets the transaction finish first.
|
|
267
|
+
window.decorView.post {
|
|
268
|
+
if (!isFinishing && !isDestroyed) navigator.route(route)
|
|
269
|
+
}
|
|
224
270
|
}
|
|
225
271
|
|
|
226
272
|
// ------------------------------------------------------------------------
|
|
@@ -380,19 +426,32 @@ class MainActivity : HotwireActivity() {
|
|
|
380
426
|
* handling selection would make More a one-shot.
|
|
381
427
|
*/
|
|
382
428
|
private fun installTabListeners(arrangement: MainTabs.Arrangement) {
|
|
383
|
-
|
|
429
|
+
val hasLeaveSlot = arrangement.slots.any { isLeaveTab(it.entry?.path) }
|
|
430
|
+
if (!arrangement.hasMore && !hasLeaveSlot) return
|
|
384
431
|
|
|
385
432
|
bottomNav.setOnItemSelectedListener { item ->
|
|
386
433
|
val index = item.itemId
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
434
|
+
when {
|
|
435
|
+
// The Jump leave tab is a native action, exactly as on iOS
|
|
436
|
+
// (EverywhereTabBarController): the page behind it is a
|
|
437
|
+
// gem-served Turbo-less fallback, so selecting its host could
|
|
438
|
+
// only end at the error view. The tap clears the picked
|
|
439
|
+
// instance directly and never becomes a selection.
|
|
440
|
+
isLeaveTab(arrangement.entryAt(index)?.path) -> {
|
|
441
|
+
leaveInstance()
|
|
442
|
+
false
|
|
443
|
+
}
|
|
444
|
+
// selectingMoreForRoute is the one case where selecting More IS
|
|
445
|
+
// the intent: something routed to an overflow path and the host
|
|
446
|
+
// has to come forward to receive it.
|
|
447
|
+
arrangement.isMoreIndex(index) && !selectingMoreForRoute -> {
|
|
448
|
+
showMoreSheet(arrangement)
|
|
449
|
+
false
|
|
450
|
+
}
|
|
451
|
+
else -> {
|
|
452
|
+
switchToSlot(index)
|
|
453
|
+
true
|
|
454
|
+
}
|
|
396
455
|
}
|
|
397
456
|
}
|
|
398
457
|
|
|
@@ -408,6 +467,19 @@ class MainActivity : HotwireActivity() {
|
|
|
408
467
|
}
|
|
409
468
|
}
|
|
410
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The injected "leave preview" tab (a Jump instance's way home), matched
|
|
472
|
+
* by its gem-reserved path. Only meaningful for instance apps — for
|
|
473
|
+
* everyone else no such tab is ever injected, and the guard keeps an
|
|
474
|
+
* unlucky app path from being swallowed.
|
|
475
|
+
*/
|
|
476
|
+
private fun isLeaveTab(path: String?): Boolean =
|
|
477
|
+
config.remoteInstances && path == JUMP_LEAVE_PATH
|
|
478
|
+
|
|
479
|
+
private fun leaveInstance() {
|
|
480
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
|
|
481
|
+
}
|
|
482
|
+
|
|
411
483
|
/**
|
|
412
484
|
* Bring a slot's host forward — upstream `HotwireBottomNavigationController`'s
|
|
413
485
|
* private `switchTab`, which taking over the item-selected listener costs us.
|
|
@@ -466,7 +538,11 @@ class MainActivity : HotwireActivity() {
|
|
|
466
538
|
|
|
467
539
|
row.setOnClickListener {
|
|
468
540
|
dialog.dismiss()
|
|
469
|
-
|
|
541
|
+
// The leave row is the same native action as a leave SLOT —
|
|
542
|
+
// see installTabListeners; routing it would load the
|
|
543
|
+
// Turbo-less fallback page and end at the error view.
|
|
544
|
+
if (isLeaveTab(entry.path)) leaveInstance()
|
|
545
|
+
else route(config.url(forPath = entry.path))
|
|
470
546
|
}
|
|
471
547
|
list.addView(row)
|
|
472
548
|
}
|
|
@@ -534,16 +610,27 @@ class MainActivity : HotwireActivity() {
|
|
|
534
610
|
lifecycleScope.launch {
|
|
535
611
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
536
612
|
Hotwire.config.pathConfiguration.loadState.collect { state ->
|
|
537
|
-
if (state is PathConfigurationLoadState.Loaded)
|
|
613
|
+
if (state is PathConfigurationLoadState.Loaded) {
|
|
614
|
+
// Only a fresh server answer can settle a rebuild
|
|
615
|
+
// chain: bundled/cached passes reflect the OLD root
|
|
616
|
+
// and match trivially right after a re-root, before
|
|
617
|
+
// the config that motivated the rebuild has landed.
|
|
618
|
+
applyPathConfiguration(
|
|
619
|
+
fresh = state is PathConfigurationLoadState.Loaded.RemoteLoaded
|
|
620
|
+
)
|
|
621
|
+
}
|
|
538
622
|
}
|
|
539
623
|
}
|
|
540
624
|
}
|
|
541
625
|
}
|
|
542
626
|
|
|
543
|
-
private fun applyPathConfiguration() {
|
|
627
|
+
private fun applyPathConfiguration(fresh: Boolean = false) {
|
|
544
628
|
val entries = MainTabs.entries()
|
|
545
629
|
val fingerprint = MainTabs.fingerprint(entries)
|
|
546
|
-
if (fingerprint == loadedFingerprint)
|
|
630
|
+
if (fingerprint == loadedFingerprint) {
|
|
631
|
+
if (fresh) settleRebuildChain()
|
|
632
|
+
return
|
|
633
|
+
}
|
|
547
634
|
|
|
548
635
|
val arrangement = MainTabs.arrange(entries, moreTitle)
|
|
549
636
|
|
|
@@ -577,14 +664,38 @@ class MainActivity : HotwireActivity() {
|
|
|
577
664
|
return
|
|
578
665
|
}
|
|
579
666
|
|
|
580
|
-
|
|
667
|
+
// ALWAYS the fresh-start rebuild, never a state-restoring
|
|
668
|
+
// recreate(): the restored back stacks belong to hosts whose
|
|
669
|
+
// start locations just changed, and restoring them mid-flight —
|
|
670
|
+
// during a reset's teardown, an instance switch's cold boot —
|
|
671
|
+
// is every framework crash this file has collected ("No
|
|
672
|
+
// configuration found for NavigatorHost", empty-URL proposals,
|
|
673
|
+
// clearAll pop-loops). iOS rebuilds its navigators fresh on a
|
|
674
|
+
// tab change too. Land where the in-flight reset was headed, or
|
|
675
|
+
// where the user is standing, or at the start.
|
|
676
|
+
val target = pendingResetTarget
|
|
677
|
+
?: delegate.currentNavigator?.location
|
|
678
|
+
?: config.startUrl
|
|
679
|
+
Log.d(TAG, "tab paths changed — fresh rebuild to $target")
|
|
581
680
|
intent.putExtra(EXTRA_LAST_REBUILD, now)
|
|
582
|
-
|
|
681
|
+
rebuildAndRoute(target)
|
|
583
682
|
return
|
|
584
683
|
}
|
|
585
684
|
|
|
586
685
|
loadedFingerprint = fingerprint
|
|
587
686
|
applyTabs(arrangement, safeSelectedIndex(arrangement))
|
|
687
|
+
if (fresh) settleRebuildChain()
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The tabs now match the loaded config with no rebuild needed — whatever
|
|
692
|
+
* rebuild chain was in flight (a reset, an instance switch) has landed.
|
|
693
|
+
* Clearing the mark stops a LATER unrelated tab-path change from being
|
|
694
|
+
* misread as part of it and redirected to a stale landing page.
|
|
695
|
+
*/
|
|
696
|
+
private fun settleRebuildChain() {
|
|
697
|
+
pendingResetTarget = null
|
|
698
|
+
intent.removeExtra(EXTRA_RESET_TARGET)
|
|
588
699
|
}
|
|
589
700
|
|
|
590
701
|
/**
|
|
@@ -727,28 +838,29 @@ class MainActivity : HotwireActivity() {
|
|
|
727
838
|
|
|
728
839
|
/**
|
|
729
840
|
* Full app reset for an auth change. Deterministic and self-contained — it
|
|
730
|
-
* does NOT depend on the reset page's JavaScript running:
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
* 3. reset every live navigator — new Session, new WebView, backstack
|
|
736
|
-
* cleared — and route the current one at `target`,
|
|
737
|
-
* 4. refetch the auth-aware config, which shows or hides the auth-gated
|
|
738
|
-
* tabs to match whoever the user is now.
|
|
841
|
+
* does NOT depend on the reset page's JavaScript running: refetch the
|
|
842
|
+
* auth-aware config (shows or hides the auth-gated tabs to match whoever
|
|
843
|
+
* the user is now) and rebuild the Activity fresh, landing on `target`.
|
|
844
|
+
* rebuildAndRoute abandons any in-flight sign-in and clears cached web
|
|
845
|
+
* content on the way.
|
|
739
846
|
*/
|
|
740
847
|
private fun resetApp(target: String) {
|
|
741
|
-
AuthFlow.cancel()
|
|
742
848
|
awaitingAuthHandoff = false
|
|
743
|
-
clearWebContentCache()
|
|
744
|
-
|
|
745
|
-
delegate.resetNavigators()
|
|
746
|
-
|
|
747
|
-
// resetNavigators rebuilds each host's graph; routing has to wait for
|
|
748
|
-
// that to land or the visit is swallowed by the rebuild.
|
|
749
|
-
bottomNav.post { route(target) }
|
|
750
849
|
|
|
850
|
+
// NOT delegate.resetNavigators() + route: the in-place reset detaches
|
|
851
|
+
// every WebView asynchronously, and each detach continuation ends in
|
|
852
|
+
// Hotwire's clearAll pop-loop — which, once the recreate that follows
|
|
853
|
+
// has made the FragmentManager save its state, spins forever on
|
|
854
|
+
// ignored popBackStack() calls and ANRs the app. A fresh Activity IS
|
|
855
|
+
// the reset (new sessions, new WebViews, empty back stacks), with no
|
|
856
|
+
// async teardown left behind to race it.
|
|
857
|
+
//
|
|
858
|
+
// The refetch starts first so the auth-aware tab set has a head start;
|
|
859
|
+
// if it still lands after the new Activity built its tabs, the
|
|
860
|
+
// EXTRA_RESET_TARGET chain (see rebuildAndRoute) finishes the job with
|
|
861
|
+
// a second fresh rebuild.
|
|
751
862
|
reloadPathConfiguration()
|
|
863
|
+
rebuildAndRoute(target)
|
|
752
864
|
}
|
|
753
865
|
|
|
754
866
|
/**
|
|
@@ -777,17 +889,54 @@ class MainActivity : HotwireActivity() {
|
|
|
777
889
|
}
|
|
778
890
|
|
|
779
891
|
/**
|
|
780
|
-
* `recreate()`
|
|
781
|
-
*
|
|
782
|
-
*
|
|
892
|
+
* `recreate()` for an in-place rebuild — a CLEAR_TASK relaunch tears the
|
|
893
|
+
* task down first, which plays as the app closing and cold-starting on
|
|
894
|
+
* every reset (sign-in, sign-out, instance switch). recreate() keeps the
|
|
895
|
+
* window; the landing page rides along on the Intent, which recreate()
|
|
896
|
+
* preserves while fields are lost.
|
|
897
|
+
*
|
|
898
|
+
* The saved instance state, however, must NOT come back: it carries every
|
|
899
|
+
* navigator's back stack, and restoring it climbs the page the user just
|
|
900
|
+
* left (Jump's connect screen, a pre-sign-in page) right back on top of
|
|
901
|
+
* the new root. `onCreate` treats the presence of [EXTRA_ROUTE] as the
|
|
902
|
+
* fresh-start flag and drops the state — see the top of [onCreate].
|
|
783
903
|
*/
|
|
784
904
|
private fun rebuildAndRoute(target: String) {
|
|
785
905
|
AuthFlow.cancel()
|
|
786
906
|
clearWebContentCache()
|
|
907
|
+
// The extras go on unconditionally: if a relaunch is already scheduled
|
|
908
|
+
// (the tab-path watcher got there first), it reuses this same Intent,
|
|
909
|
+
// and EXTRA_ROUTE upgrades it from a state-restoring recreate to the
|
|
910
|
+
// fresh-start rebuild a reset needs.
|
|
911
|
+
//
|
|
912
|
+
// EXTRA_RESET_TARGET marks the whole chain: the next Activity boots
|
|
913
|
+
// its tabs from whatever config is loaded NOW, and the refreshed
|
|
914
|
+
// config (an instance's tabs, the post-auth tab set) usually lands
|
|
915
|
+
// moments later. When that changes the tab paths, the watcher must
|
|
916
|
+
// rebuild fresh again, aimed here — never restore. The mark is
|
|
917
|
+
// cleared when a config pass finds nothing left to rebuild.
|
|
918
|
+
pendingResetTarget = target
|
|
787
919
|
intent.putExtra(EXTRA_ROUTE, target)
|
|
920
|
+
intent.putExtra(EXTRA_RESET_TARGET, target)
|
|
921
|
+
if (rebuildScheduled) return
|
|
922
|
+
rebuildScheduled = true
|
|
788
923
|
recreate()
|
|
789
924
|
}
|
|
790
925
|
|
|
926
|
+
/**
|
|
927
|
+
* The reset route (`/everywhere/reset` after a sign-in or sign-out) calls
|
|
928
|
+
* this DIRECTLY from its route decision handler rather than through
|
|
929
|
+
* [EverywhereEvents]: a sign-in changes the auth-gated tab paths moments
|
|
930
|
+
* later, and if the event collector loses the race to the tab-path
|
|
931
|
+
* watcher's `recreate()`, the app restores the half-dismissed pre-sign-in
|
|
932
|
+
* back stack — mid-flight WebView teardown included — and crashes inside
|
|
933
|
+
* the framework. Scheduling the fresh-start relaunch synchronously, before
|
|
934
|
+
* the config refresh can land, makes the ordering deterministic.
|
|
935
|
+
*/
|
|
936
|
+
fun resetAppNow(to: String?) {
|
|
937
|
+
resetApp(resetTarget(to))
|
|
938
|
+
}
|
|
939
|
+
|
|
791
940
|
/**
|
|
792
941
|
* Drop web storage so a page isn't served from its pre-auth state. Cookies
|
|
793
942
|
* are left intact — the server has already invalidated the session on
|
|
@@ -954,8 +1103,13 @@ class MainActivity : HotwireActivity() {
|
|
|
954
1103
|
const val TAG = "Everywhere"
|
|
955
1104
|
const val STATE_SELECTED_TAB = "everywhere.selectedTab"
|
|
956
1105
|
const val EXTRA_ROUTE = "everywhere.route"
|
|
1106
|
+
const val EXTRA_RESET_TARGET = "everywhere.resetTarget"
|
|
957
1107
|
const val EXTRA_LAST_REBUILD = "everywhere.lastRebuild"
|
|
958
1108
|
|
|
1109
|
+
/** The injected leave tab's path — same constant iOS keeps on
|
|
1110
|
+
* EverywhereTabBarController.jumpLeavePath. */
|
|
1111
|
+
const val JUMP_LEAVE_PATH = "/everywhere/jump/leave"
|
|
1112
|
+
|
|
959
1113
|
/**
|
|
960
1114
|
* How close two path-change rebuilds have to be before the second is
|
|
961
1115
|
* treated as a loop rather than a legitimate config change. Generous
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.graphics.drawable.Drawable
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.util.TypedValue
|
|
6
|
+
import android.view.Gravity
|
|
7
|
+
import android.view.LayoutInflater
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.view.ViewGroup
|
|
10
|
+
import android.widget.FrameLayout
|
|
11
|
+
import android.widget.ImageView
|
|
12
|
+
import android.widget.LinearLayout
|
|
13
|
+
import android.widget.TextView
|
|
14
|
+
import androidx.appcompat.widget.Toolbar
|
|
15
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
16
|
+
import com.google.android.material.appbar.AppBarLayout
|
|
17
|
+
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
18
|
+
import dev.hotwire.navigation.fragments.HotwireFragment
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The per-rule fallback destination for native screens this build doesn't
|
|
22
|
+
* contain — the Android half of iOS's `MissingExtensionScreen`.
|
|
23
|
+
*
|
|
24
|
+
* The gem writes `fallback_uri: hotwire://fragment/everywhere-missing` next to
|
|
25
|
+
* every derived screen `uri` (see the gem's Config::Mobile), so when a
|
|
26
|
+
* Jump-style shell previews an app whose rules name Fragments compiled into
|
|
27
|
+
* THAT app's build, the visit lands here and says so — instead of Hotwire
|
|
28
|
+
* finding no destination and leaving the user on a spinner forever. An app's
|
|
29
|
+
* own build resolves the primary uri and never consults the fallback.
|
|
30
|
+
*/
|
|
31
|
+
@HotwireDestinationDeepLink(uri = "hotwire://fragment/everywhere-missing")
|
|
32
|
+
class MissingExtensionFragment : HotwireFragment() {
|
|
33
|
+
private lateinit var toolbar: Toolbar
|
|
34
|
+
|
|
35
|
+
override fun onCreateView(
|
|
36
|
+
inflater: LayoutInflater,
|
|
37
|
+
container: ViewGroup?,
|
|
38
|
+
savedInstanceState: Bundle?
|
|
39
|
+
): View {
|
|
40
|
+
val context = requireContext()
|
|
41
|
+
// The rule that routed here still carries the screen's identifier.
|
|
42
|
+
val identifier = pathProperties["view_controller"]?.toString() ?: "native"
|
|
43
|
+
|
|
44
|
+
toolbar = Toolbar(context).apply {
|
|
45
|
+
title = getString(R.string.everywhere_missing_screen_nav_title)
|
|
46
|
+
}
|
|
47
|
+
val appBar = AppBarLayout(context).apply {
|
|
48
|
+
fitsSystemWindows = true
|
|
49
|
+
addView(toolbar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
val secondary = TypedValue().let {
|
|
53
|
+
context.theme.resolveAttribute(android.R.attr.textColorSecondary, it, true)
|
|
54
|
+
requireNotNull(context.getColorStateList(it.resourceId)).defaultColor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
val icon = ImageView(context).apply {
|
|
58
|
+
setImageDrawable(tinted(IconFont.drawable(context, "extension", dp(44)), secondary))
|
|
59
|
+
}
|
|
60
|
+
val title = TextView(context).apply {
|
|
61
|
+
text = getString(R.string.everywhere_missing_screen_title)
|
|
62
|
+
textSize = 20f
|
|
63
|
+
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
|
64
|
+
gravity = Gravity.CENTER
|
|
65
|
+
}
|
|
66
|
+
val body = TextView(context).apply {
|
|
67
|
+
text = getString(R.string.everywhere_missing_screen_body, identifier)
|
|
68
|
+
textSize = 15f
|
|
69
|
+
setTextColor(secondary)
|
|
70
|
+
gravity = Gravity.CENTER
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
val column = LinearLayout(context).apply {
|
|
74
|
+
orientation = LinearLayout.VERTICAL
|
|
75
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
76
|
+
setPadding(dp(32), 0, dp(32), 0)
|
|
77
|
+
addView(icon)
|
|
78
|
+
addView(title, spaced(dp(14)))
|
|
79
|
+
addView(body, spaced(dp(14)))
|
|
80
|
+
}
|
|
81
|
+
val content = FrameLayout(context).apply {
|
|
82
|
+
addView(column, FrameLayout.LayoutParams(
|
|
83
|
+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT,
|
|
84
|
+
Gravity.CENTER
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return LinearLayout(context).apply {
|
|
89
|
+
orientation = LinearLayout.VERTICAL
|
|
90
|
+
addView(appBar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
91
|
+
addView(content, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun toolbarForNavigation(): Toolbar = toolbar
|
|
96
|
+
|
|
97
|
+
private fun tinted(drawable: Drawable, color: Int): Drawable =
|
|
98
|
+
DrawableCompat.wrap(drawable).mutate().also { DrawableCompat.setTint(it, color) }
|
|
99
|
+
|
|
100
|
+
private fun spaced(marginTop: Int) = LinearLayout.LayoutParams(
|
|
101
|
+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
102
|
+
).apply { topMargin = marginTop }
|
|
103
|
+
|
|
104
|
+
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
|
105
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
package com.rubyeverywhere.shell
|
|
2
2
|
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import android.view.Menu
|
|
5
|
+
import android.view.MenuItem
|
|
6
|
+
import android.view.View
|
|
7
|
+
import android.widget.TextView
|
|
8
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
9
|
+
import androidx.core.view.isVisible
|
|
3
10
|
import dev.hotwire.core.turbo.errors.VisitError
|
|
4
11
|
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
5
12
|
import dev.hotwire.navigation.fragments.HotwireWebFragment
|
|
@@ -8,14 +15,63 @@ import dev.hotwire.navigation.fragments.HotwireWebFragment
|
|
|
8
15
|
* The default destination for every web visit.
|
|
9
16
|
*
|
|
10
17
|
* Deliberately thin — the framework's own fragment already does the work. Its
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* jobs are telling [MainActivity] when there is content on screen (how the
|
|
19
|
+
* splash comes down — Android has no `NavigatorDelegate.requestDidFinish` the
|
|
20
|
+
* way iOS does; the visit callbacks on the fragment are where that information
|
|
21
|
+
* surfaces) and swapping the library's button-less error view for one the user
|
|
22
|
+
* can act on.
|
|
15
23
|
*/
|
|
16
24
|
@HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
|
|
17
25
|
open class WebFragment : HotwireWebFragment() {
|
|
18
26
|
|
|
27
|
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
28
|
+
super.onViewCreated(view, savedInstanceState)
|
|
29
|
+
addLeavePreviewAction()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* While a picked instance is active, screens whose TAB BAR is hidden get
|
|
34
|
+
* the way home in their toolbar instead. The injected leave TAB alone is
|
|
35
|
+
* not enough: Hotwire hides the tab bar on modal-context screens, and a
|
|
36
|
+
* previewed app's whole signed-out surface can be modal (`/session/new`
|
|
37
|
+
* under a `/new$` rule) — leaving no escape at all once a sign-out lands
|
|
38
|
+
* there. Where the bar IS showing, its Jump tab already covers this, so
|
|
39
|
+
* the toolbar stays the app's own. `remote.instances_escape: false`
|
|
40
|
+
* turns the whole affordance off.
|
|
41
|
+
*/
|
|
42
|
+
private fun addLeavePreviewAction() {
|
|
43
|
+
val config = EverywhereConfig.shared
|
|
44
|
+
if (!config.instancesEscape || config.instanceUrl == null) return
|
|
45
|
+
|
|
46
|
+
val modal = pathProperties["context"]?.toString() == "modal"
|
|
47
|
+
val barAvailable = (activity as? MainActivity)?.hasTabBar == true
|
|
48
|
+
if (!modal && barAvailable) return
|
|
49
|
+
|
|
50
|
+
val toolbar = toolbarForNavigation() ?: return
|
|
51
|
+
if (toolbar.menu.findItem(MENU_LEAVE_PREVIEW) != null) return
|
|
52
|
+
|
|
53
|
+
val sizePx = (22 * resources.displayMetrics.density).toInt()
|
|
54
|
+
val tint = requireContext().let { context ->
|
|
55
|
+
val value = android.util.TypedValue()
|
|
56
|
+
context.theme.resolveAttribute(
|
|
57
|
+
com.google.android.material.R.attr.colorOnSurface, value, true
|
|
58
|
+
)
|
|
59
|
+
context.getColor(value.resourceId)
|
|
60
|
+
}
|
|
61
|
+
toolbar.menu.add(
|
|
62
|
+
Menu.NONE, MENU_LEAVE_PREVIEW, Menu.NONE, R.string.everywhere_error_leave
|
|
63
|
+
).apply {
|
|
64
|
+
icon = DrawableCompat.wrap(
|
|
65
|
+
IconFont.drawable(requireContext(), "u_turn_left", sizePx)
|
|
66
|
+
).mutate().also { DrawableCompat.setTint(it, tint) }
|
|
67
|
+
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
|
68
|
+
setOnMenuItemClickListener {
|
|
69
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
|
|
70
|
+
true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
19
75
|
override fun onVisitRequestFinished(location: String) {
|
|
20
76
|
super.onVisitRequestFinished(location)
|
|
21
77
|
settled()
|
|
@@ -30,7 +86,35 @@ open class WebFragment : HotwireWebFragment() {
|
|
|
30
86
|
settled()
|
|
31
87
|
}
|
|
32
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The library's error view is a message with no controls — its only retry
|
|
91
|
+
* is pull-to-refresh, invisible exactly when the user is staring at a dead
|
|
92
|
+
* page. Ours adds an explicit retry, and — inside a picked instance, where
|
|
93
|
+
* retrying can only knock on the same dead host — the way home: clear the
|
|
94
|
+
* instance and re-root onto the app's own start page, exactly what the
|
|
95
|
+
* page's own `Everywhere.instance.clear()` would do if it could load.
|
|
96
|
+
*/
|
|
97
|
+
override fun createErrorView(error: VisitError): View {
|
|
98
|
+
val view = layoutInflater.inflate(R.layout.everywhere_error, null)
|
|
99
|
+
view.findViewById<TextView>(R.id.everywhere_error_description).text = error.description()
|
|
100
|
+
view.findViewById<View>(R.id.everywhere_error_retry).setOnClickListener {
|
|
101
|
+
refresh(displayProgress = true)
|
|
102
|
+
}
|
|
103
|
+
view.findViewById<View>(R.id.everywhere_error_leave).apply {
|
|
104
|
+
isVisible = EverywhereConfig.shared.instanceUrl != null
|
|
105
|
+
setOnClickListener {
|
|
106
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return view
|
|
110
|
+
}
|
|
111
|
+
|
|
33
112
|
private fun settled() {
|
|
34
113
|
(activity as? MainActivity)?.onWebContentSettled()
|
|
35
114
|
}
|
|
115
|
+
|
|
116
|
+
private companion object {
|
|
117
|
+
/** Stable menu id so re-created toolbars don't stack duplicates. */
|
|
118
|
+
const val MENU_LEAVE_PREVIEW = 0x00EE01
|
|
119
|
+
}
|
|
36
120
|
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt
CHANGED
|
@@ -189,16 +189,12 @@ class WebControlChannel : WebViewCompat.WebMessageListener {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* Every origin the shell's own pages come from — stamped root, dev
|
|
193
|
+
* override, picked instance (EverywhereConfig.trustedRootOrigins).
|
|
194
|
+
* All of them, not just the effective root: while the shell is rooted
|
|
195
|
+
* on an instance, the picker's own pages are still served from the
|
|
196
|
+
* stamped origin, and they need the channel to switch or leave.
|
|
195
197
|
*/
|
|
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
|
-
}
|
|
198
|
+
private fun originRules(): Set<String> = EverywhereConfig.shared.trustedRootOrigins
|
|
203
199
|
}
|
|
204
200
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
FROZEN. The failed-visit screen WebFragment.createErrorView inflates in
|
|
4
|
+
place of the library's hotwire_error.xml, which has no controls at all —
|
|
5
|
+
its only retry is pull-to-refresh, invisible exactly when the user is
|
|
6
|
+
staring at a dead page. Same typography as the library's version, plus an
|
|
7
|
+
explicit retry button and an escape hatch for picked instances (shown by
|
|
8
|
+
WebFragment only when an instance override is active).
|
|
9
|
+
-->
|
|
10
|
+
<FrameLayout
|
|
11
|
+
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
12
|
+
android:layout_width="match_parent"
|
|
13
|
+
android:layout_height="match_parent"
|
|
14
|
+
android:background="?android:colorBackground">
|
|
15
|
+
|
|
16
|
+
<LinearLayout
|
|
17
|
+
android:layout_width="match_parent"
|
|
18
|
+
android:layout_height="wrap_content"
|
|
19
|
+
android:layout_gravity="center"
|
|
20
|
+
android:orientation="vertical"
|
|
21
|
+
android:gravity="center_horizontal"
|
|
22
|
+
android:paddingStart="24dp"
|
|
23
|
+
android:paddingEnd="24dp">
|
|
24
|
+
|
|
25
|
+
<com.google.android.material.textview.MaterialTextView
|
|
26
|
+
style="@style/TextAppearance.MaterialComponents.Headline6"
|
|
27
|
+
android:id="@+id/everywhere_error_title"
|
|
28
|
+
android:layout_width="match_parent"
|
|
29
|
+
android:layout_height="wrap_content"
|
|
30
|
+
android:gravity="center_horizontal"
|
|
31
|
+
android:text="@string/everywhere_error_title" />
|
|
32
|
+
|
|
33
|
+
<com.google.android.material.textview.MaterialTextView
|
|
34
|
+
style="@style/TextAppearance.MaterialComponents.Caption"
|
|
35
|
+
android:id="@+id/everywhere_error_description"
|
|
36
|
+
android:layout_width="match_parent"
|
|
37
|
+
android:layout_height="wrap_content"
|
|
38
|
+
android:layout_marginTop="8dp"
|
|
39
|
+
android:gravity="center_horizontal"
|
|
40
|
+
android:textSize="14sp" />
|
|
41
|
+
|
|
42
|
+
<com.google.android.material.button.MaterialButton
|
|
43
|
+
android:id="@+id/everywhere_error_retry"
|
|
44
|
+
android:layout_width="wrap_content"
|
|
45
|
+
android:layout_height="wrap_content"
|
|
46
|
+
android:layout_marginTop="24dp"
|
|
47
|
+
android:text="@string/everywhere_error_retry" />
|
|
48
|
+
|
|
49
|
+
<com.google.android.material.button.MaterialButton
|
|
50
|
+
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
51
|
+
android:id="@+id/everywhere_error_leave"
|
|
52
|
+
android:layout_width="wrap_content"
|
|
53
|
+
android:layout_height="wrap_content"
|
|
54
|
+
android:layout_marginTop="8dp"
|
|
55
|
+
android:text="@string/everywhere_error_leave"
|
|
56
|
+
android:visibility="gone" />
|
|
57
|
+
|
|
58
|
+
</LinearLayout>
|
|
59
|
+
|
|
60
|
+
</FrameLayout>
|
|
@@ -15,4 +15,23 @@
|
|
|
15
15
|
names its own overflow tab on iOS.
|
|
16
16
|
-->
|
|
17
17
|
<string name="everywhere_more_tab">More</string>
|
|
18
|
+
<!--
|
|
19
|
+
The failed-visit screen (WebFragment.createErrorView). "Back to start"
|
|
20
|
+
shows only inside a picked instance, where retrying the same dead host
|
|
21
|
+
is the only other move — it clears the instance and re-roots.
|
|
22
|
+
-->
|
|
23
|
+
<string name="everywhere_error_title">Error loading page</string>
|
|
24
|
+
<string name="everywhere_error_retry">Try again</string>
|
|
25
|
+
<string name="everywhere_error_leave">Back to start</string>
|
|
26
|
+
<!--
|
|
27
|
+
MissingExtensionFragment — the fallback destination when a previewed
|
|
28
|
+
app's rule names a native screen compiled into ITS build, not this
|
|
29
|
+
shell. Copy mirrors iOS's MissingExtensionScreen.
|
|
30
|
+
-->
|
|
31
|
+
<string name="everywhere_missing_screen_nav_title">Preview</string>
|
|
32
|
+
<string name="everywhere_missing_screen_title">This screen is native</string>
|
|
33
|
+
<string name="everywhere_missing_screen_body">In the full build, “%1$s” is a native screen compiled into the app. Jump can\'t run another app\'s native code — everything else in this preview works.</string>
|
|
34
|
+
<!-- Older previewed apps serve no fallback_uri; their native-screen visits
|
|
35
|
+
are refused with this toast instead of a spinner that never ends. -->
|
|
36
|
+
<string name="everywhere_missing_screen_toast">“%1$s” is a native screen in the full build — Jump can\'t run another app\'s native code.</string>
|
|
18
37
|
</resources>
|
|
@@ -55,6 +55,10 @@ final class MenuComponent: BridgeComponent {
|
|
|
55
55
|
// order ("Edit", "Share") lands left-to-right as authored.
|
|
56
56
|
viewController.navigationItem.leftBarButtonItems = left.isEmpty ? nil : left
|
|
57
57
|
viewController.navigationItem.rightBarButtonItems = right.isEmpty ? nil : right.reversed()
|
|
58
|
+
|
|
59
|
+
// The wholesale replacement above just dropped the shell's
|
|
60
|
+
// instance-escape action, if this screen carries one — put it back.
|
|
61
|
+
(viewController as? EverywhereWebViewController)?.appendLeaveItemIfMissing()
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
private func barButtonItem(for item: NavItem) -> UIBarButtonItem {
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import UIKit
|
|
2
2
|
|
|
3
3
|
/// A full-screen error page with a retry button, presented when a visit fails
|
|
4
|
-
/// (e.g. the server is unreachable).
|
|
4
|
+
/// (e.g. the server is unreachable). When the failure happened inside a picked
|
|
5
|
+
/// instance — where retrying can only knock on the same dead host — a second
|
|
6
|
+
/// button offers the way home (SceneDelegate passes `leaveHandler` exactly
|
|
7
|
+
/// then, and it clears the instance and re-roots).
|
|
5
8
|
final class ErrorViewController: UIViewController {
|
|
6
9
|
private let error: Error
|
|
7
10
|
private let retryHandler: (() -> Void)?
|
|
11
|
+
private let leaveHandler: (() -> Void)?
|
|
8
12
|
|
|
9
|
-
init(error: Error, retryHandler: (() -> Void)?) {
|
|
13
|
+
init(error: Error, retryHandler: (() -> Void)?, leaveHandler: (() -> Void)? = nil) {
|
|
10
14
|
self.error = error
|
|
11
15
|
self.retryHandler = retryHandler
|
|
16
|
+
self.leaveHandler = leaveHandler
|
|
12
17
|
super.init(nibName: nil, bundle: nil)
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -41,7 +46,15 @@ final class ErrorViewController: UIViewController {
|
|
|
41
46
|
})
|
|
42
47
|
retryButton.isHidden = retryHandler == nil
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
var leaveConfiguration = UIButton.Configuration.plain()
|
|
50
|
+
leaveConfiguration.title = "Back to start"
|
|
51
|
+
leaveConfiguration.buttonSize = .large
|
|
52
|
+
let leaveButton = UIButton(configuration: leaveConfiguration, primaryAction: UIAction { [weak self] _ in
|
|
53
|
+
self?.leave()
|
|
54
|
+
})
|
|
55
|
+
leaveButton.isHidden = leaveHandler == nil
|
|
56
|
+
|
|
57
|
+
let stackView = UIStackView(arrangedSubviews: [imageView, messageLabel, retryButton, leaveButton])
|
|
45
58
|
stackView.axis = .vertical
|
|
46
59
|
stackView.alignment = .center
|
|
47
60
|
stackView.spacing = 16
|
|
@@ -61,4 +74,9 @@ final class ErrorViewController: UIViewController {
|
|
|
61
74
|
retryHandler?()
|
|
62
75
|
dismiss(animated: true)
|
|
63
76
|
}
|
|
77
|
+
|
|
78
|
+
private func leave() {
|
|
79
|
+
dismiss(animated: true)
|
|
80
|
+
leaveHandler?()
|
|
81
|
+
}
|
|
64
82
|
}
|
|
@@ -77,6 +77,10 @@ struct EverywhereConfig: Decodable {
|
|
|
77
77
|
let mode: String?
|
|
78
78
|
let remoteUrl: String?
|
|
79
79
|
let remoteInstances: Bool?
|
|
80
|
+
/// `remote.instances_escape` — the built-in "back to start" nav-bar action
|
|
81
|
+
/// offered while re-rooted, on screens the tab bar (and its injected
|
|
82
|
+
/// leave tab) can't reach: modals, and instances serving no tabs.
|
|
83
|
+
let instancesEscape: Bool?
|
|
80
84
|
let entryPath: String?
|
|
81
85
|
let tintColor: ThemedColor?
|
|
82
86
|
let backgroundColor: ThemedColor?
|
|
@@ -92,6 +96,7 @@ struct EverywhereConfig: Decodable {
|
|
|
92
96
|
case mode
|
|
93
97
|
case remoteUrl = "remote_url"
|
|
94
98
|
case remoteInstances = "remote_instances"
|
|
99
|
+
case instancesEscape = "instances_escape"
|
|
95
100
|
case entryPath = "entry_path"
|
|
96
101
|
case tintColor = "tint_color"
|
|
97
102
|
case backgroundColor = "background_color"
|
|
@@ -570,7 +570,10 @@ extension SceneDelegate: NavigatorDelegate {
|
|
|
570
570
|
return .acceptCustom(everywhereHost(MissingExtensionScreen(identifier: identifier)))
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
|
-
|
|
573
|
+
// The shell's own web controller rather than the framework default —
|
|
574
|
+
// it carries the instance-escape nav action on screens the tab bar
|
|
575
|
+
// can't reach (see EverywhereWebViewController).
|
|
576
|
+
return .acceptCustom(EverywhereWebViewController(url: proposal.url))
|
|
574
577
|
}
|
|
575
578
|
|
|
576
579
|
func requestDidFinish(at url: URL) {
|
|
@@ -599,7 +602,18 @@ extension SceneDelegate: NavigatorDelegate {
|
|
|
599
602
|
}
|
|
600
603
|
|
|
601
604
|
private func presentError(_ error: HotwireNativeError, retryHandler: RetryBlock?) {
|
|
602
|
-
|
|
605
|
+
// Inside a picked instance whose server has gone away, Retry can only
|
|
606
|
+
// knock on the same dead host — offer the way home too: clear the
|
|
607
|
+
// instance and re-root, exactly what the leave tab does when it can load.
|
|
608
|
+
var leaveHandler: (() -> Void)?
|
|
609
|
+
if config.remoteInstances == true, EverywhereConfig.instanceURL != nil {
|
|
610
|
+
leaveHandler = { [weak self] in
|
|
611
|
+
guard let self else { return }
|
|
612
|
+
EverywhereConfig.setInstanceURL(nil)
|
|
613
|
+
self.resetApp(to: self.config.startURL)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler, leaveHandler: leaveHandler)
|
|
603
617
|
errorViewController.modalPresentationStyle = .fullScreen
|
|
604
618
|
activeNavigator.activeNavigationController.present(errorViewController, animated: true)
|
|
605
619
|
}
|
|
@@ -652,3 +666,60 @@ struct MissingExtensionScreen: View {
|
|
|
652
666
|
.navigationTitle("Preview")
|
|
653
667
|
}
|
|
654
668
|
}
|
|
669
|
+
|
|
670
|
+
/// The shell's web view controller — `handle(proposal:)` returns it for every
|
|
671
|
+
/// accepted web visit instead of the framework default.
|
|
672
|
+
///
|
|
673
|
+
/// Its one addition: while a picked instance is active
|
|
674
|
+
/// (`remote.instances: true` + `Everywhere.instance.set`), screens the tab bar
|
|
675
|
+
/// can't reach carry the way home as a nav-bar action. The injected leave TAB
|
|
676
|
+
/// alone is not enough — modals present over the tab bar, and a previewed
|
|
677
|
+
/// app's whole signed-out surface can be modal (`/session/new` under a `/new$`
|
|
678
|
+
/// rule), leaving no escape once a sign-out lands there. Where the tab bar IS
|
|
679
|
+
/// visible its Jump tab already covers this, so the nav bar stays the app's
|
|
680
|
+
/// own. `remote: { instances_escape: false }` turns the affordance off.
|
|
681
|
+
final class EverywhereWebViewController: HotwireWebViewController {
|
|
682
|
+
/// Exposed so MenuComponent can re-append it after a page's own
|
|
683
|
+
/// `everywhere_nav_button`s replace `rightBarButtonItems` wholesale.
|
|
684
|
+
private(set) var leaveBarButtonItem: UIBarButtonItem?
|
|
685
|
+
|
|
686
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
687
|
+
super.viewWillAppear(animated)
|
|
688
|
+
addLeavePreviewActionIfNeeded()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private func addLeavePreviewActionIfNeeded() {
|
|
692
|
+
let config = EverywhereConfig.shared
|
|
693
|
+
guard config.remoteInstances == true,
|
|
694
|
+
config.instancesEscape == true,
|
|
695
|
+
EverywhereConfig.instanceURL != nil,
|
|
696
|
+
// A reachable tab bar means the leave tab is available — stand
|
|
697
|
+
// down. Modals (their own nav stack) and bar-less roots land here.
|
|
698
|
+
tabBarController == nil
|
|
699
|
+
else { return }
|
|
700
|
+
|
|
701
|
+
if leaveBarButtonItem == nil {
|
|
702
|
+
let item = UIBarButtonItem(
|
|
703
|
+
image: UIImage(systemName: "arrow.uturn.backward"),
|
|
704
|
+
primaryAction: UIAction { _ in
|
|
705
|
+
NotificationCenter.default.post(name: .everywhereClearInstance, object: nil)
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
item.accessibilityLabel = "Back to start"
|
|
709
|
+
leaveBarButtonItem = item
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
appendLeaveItemIfMissing()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/// Idempotent: the page's own nav buttons may have replaced the items —
|
|
716
|
+
/// MenuComponent calls this again after it rebuilds them.
|
|
717
|
+
func appendLeaveItemIfMissing() {
|
|
718
|
+
guard let item = leaveBarButtonItem else { return }
|
|
719
|
+
let items = navigationItem.rightBarButtonItems ?? []
|
|
720
|
+
guard !items.contains(item) else { return }
|
|
721
|
+
// First in the array is the rightmost slot — the escape stays at the
|
|
722
|
+
// far edge, clear of the page's own actions.
|
|
723
|
+
navigationItem.rightBarButtonItems = [item] + items
|
|
724
|
+
}
|
|
725
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_everywhere
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrea Fomera
|
|
@@ -240,6 +240,7 @@ files:
|
|
|
240
240
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt
|
|
241
241
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt
|
|
242
242
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt
|
|
243
|
+
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt
|
|
243
244
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt
|
|
244
245
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt
|
|
245
246
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt
|
|
@@ -257,6 +258,7 @@ files:
|
|
|
257
258
|
- support/mobile/android/app/src/main/res/layout/activity_main.xml
|
|
258
259
|
- support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml
|
|
259
260
|
- support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml
|
|
261
|
+
- support/mobile/android/app/src/main/res/layout/everywhere_error.xml
|
|
260
262
|
- support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml
|
|
261
263
|
- support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml
|
|
262
264
|
- support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
|