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,975 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Intent
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Bundle
|
|
6
|
+
import android.os.SystemClock
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import android.util.TypedValue
|
|
9
|
+
import android.view.LayoutInflater
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.view.ViewGroup
|
|
12
|
+
import android.webkit.CookieManager
|
|
13
|
+
import android.webkit.WebStorage
|
|
14
|
+
import android.widget.FrameLayout
|
|
15
|
+
import android.widget.ImageView
|
|
16
|
+
import android.widget.LinearLayout
|
|
17
|
+
import android.widget.TextView
|
|
18
|
+
import androidx.activity.enableEdgeToEdge
|
|
19
|
+
import androidx.appcompat.app.AlertDialog
|
|
20
|
+
import androidx.core.content.ContextCompat
|
|
21
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
22
|
+
import androidx.core.view.isVisible
|
|
23
|
+
import androidx.lifecycle.Lifecycle
|
|
24
|
+
import androidx.lifecycle.lifecycleScope
|
|
25
|
+
import androidx.lifecycle.repeatOnLifecycle
|
|
26
|
+
import com.google.android.material.bottomnavigation.BottomNavigationView
|
|
27
|
+
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
28
|
+
import com.rubyeverywhere.shell.extensions.EverywhereExtensions
|
|
29
|
+
import dev.hotwire.core.config.Hotwire
|
|
30
|
+
import dev.hotwire.core.turbo.config.PathConfiguration
|
|
31
|
+
import dev.hotwire.core.turbo.config.PathConfigurationLoadState
|
|
32
|
+
import dev.hotwire.navigation.activities.HotwireActivity
|
|
33
|
+
import dev.hotwire.navigation.navigator.Navigator
|
|
34
|
+
import dev.hotwire.navigation.navigator.NavigatorConfiguration
|
|
35
|
+
import dev.hotwire.navigation.tabs.HotwireBottomNavigationController
|
|
36
|
+
import dev.hotwire.navigation.tabs.HotwireBottomTab
|
|
37
|
+
import dev.hotwire.navigation.util.applyDefaultImeWindowInsets
|
|
38
|
+
import kotlinx.coroutines.launch
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The shell's only Activity — the Android counterpart of iOS's SceneDelegate,
|
|
42
|
+
* carrying the same responsibilities: build the tab bar from the auth-aware
|
|
43
|
+
* path configuration, keep it in sync as that configuration changes, hold the
|
|
44
|
+
* splash until first content, act on the page's control messages, route deep
|
|
45
|
+
* links, and run third-party sign-in outside the web view.
|
|
46
|
+
*/
|
|
47
|
+
class MainActivity : HotwireActivity() {
|
|
48
|
+
|
|
49
|
+
private val config = EverywhereConfig.shared
|
|
50
|
+
|
|
51
|
+
private lateinit var bottomNav: BottomNavigationView
|
|
52
|
+
private lateinit var tabController: HotwireBottomNavigationController
|
|
53
|
+
private lateinit var splash: FrameLayout
|
|
54
|
+
|
|
55
|
+
/** The bar item that stands for every tab past the fourth. See [MainTabs.arrange]. */
|
|
56
|
+
private val moreTitle: String by lazy { getString(R.string.everywhere_more_tab) }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The tab set this Activity was built around. Frozen for the Activity's
|
|
60
|
+
* lifetime on purpose: `navigatorConfigurations()` is read by
|
|
61
|
+
* `HotwireActivityDelegate`'s constructor and again by each of the five
|
|
62
|
+
* `NavigatorHost.onCreate`s, and every one of them must see the same five
|
|
63
|
+
* configurations. A list that changed underneath them is the
|
|
64
|
+
* `IllegalStateException("No configuration found for NavigatorHost")` case.
|
|
65
|
+
*/
|
|
66
|
+
private val initialArrangement: MainTabs.Arrangement by lazy {
|
|
67
|
+
MainTabs.arrange(MainTabs.entries(), moreTitle)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private val pooledConfigurations: List<NavigatorConfiguration> by lazy {
|
|
71
|
+
MainTabs.tabs(config, initialArrangement).map { it.configuration }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The arrangement backing whatever is on screen right now. */
|
|
75
|
+
private var currentArrangement: MainTabs.Arrangement =
|
|
76
|
+
MainTabs.Arrangement(emptyList(), emptyList(), emptyList())
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fingerprints of what is on screen, so an unchanged config refresh
|
|
80
|
+
* rebuilds nothing. [loadedPathsFingerprint] is the expensive one: when it
|
|
81
|
+
* changes, the hosts were built against the wrong start locations and only
|
|
82
|
+
* `recreate()` can fix that.
|
|
83
|
+
*/
|
|
84
|
+
private var loadedFingerprint: String? = null
|
|
85
|
+
private var loadedPathsFingerprint: String? = null
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Last badge per tab path. Pages usually set badges from the first page
|
|
89
|
+
* load — often before the auth-gated tab bar exists — so badges are stored
|
|
90
|
+
* here and re-applied whenever the tab bar is (re)built. They have to be:
|
|
91
|
+
* `loadMenu()` opens with `view.menu.clear()`, which drops every badge
|
|
92
|
+
* drawable along with the menu.
|
|
93
|
+
*/
|
|
94
|
+
private val tabBadges = mutableMapOf<String, Int>()
|
|
95
|
+
|
|
96
|
+
/** Cold-launch splash timing; see [dismissSplash]. */
|
|
97
|
+
private var splashShownAt = 0L
|
|
98
|
+
private var splashIsCustom = false
|
|
99
|
+
private var splashDismissScheduled = false
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Set while the web view is spending a handoff token. The visit that
|
|
103
|
+
* follows carries the Set-Cookie headers that sign the app in, so the
|
|
104
|
+
* auth-gated tab list can only be fetched once it has finished.
|
|
105
|
+
*/
|
|
106
|
+
private var awaitingAuthHandoff = false
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A location that arrived before any navigator was ready — a deep link on a
|
|
110
|
+
* cold launch, or the landing page after an instance switch rebuilt the
|
|
111
|
+
* Activity. Held until the host it belongs to reports in, then routed.
|
|
112
|
+
*
|
|
113
|
+
* [pendingRouteHostId] pins it to one host when the destination is an
|
|
114
|
+
* overflow tab: that route was aimed at the More host specifically, and a
|
|
115
|
+
* different host reporting ready first would otherwise swallow it.
|
|
116
|
+
*/
|
|
117
|
+
private var pendingRoute: String? = null
|
|
118
|
+
private var pendingRouteHostId: Int? = null
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Set while the More tab is being selected in code rather than by a tap.
|
|
122
|
+
* Selecting an item programmatically runs the same listeners a tap does, and
|
|
123
|
+
* this is the one case where selecting More is meant literally: a route to
|
|
124
|
+
* an overflow path (a picked sheet row, a deep link, `Everywhere.visit`)
|
|
125
|
+
* needs the host to come forward and receive it — not to be refused, and not
|
|
126
|
+
* to be answered with the sheet.
|
|
127
|
+
*/
|
|
128
|
+
private var selectingMoreForRoute = false
|
|
129
|
+
|
|
130
|
+
/** The open More sheet, so a second tap doesn't stack a second one. */
|
|
131
|
+
private var moreSheet: BottomSheetDialog? = null
|
|
132
|
+
|
|
133
|
+
// ------------------------------------------------------------------------
|
|
134
|
+
// Lifecycle
|
|
135
|
+
// ------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
138
|
+
// The manifest launches this Activity with Theme.Everywhere.Splash so
|
|
139
|
+
// the system's starting window is already the app's background colour.
|
|
140
|
+
// Swap to the real theme before the content view inflates.
|
|
141
|
+
setTheme(R.style.Theme_Everywhere)
|
|
142
|
+
enableEdgeToEdge()
|
|
143
|
+
super.onCreate(savedInstanceState)
|
|
144
|
+
|
|
145
|
+
setContentView(R.layout.activity_main)
|
|
146
|
+
findViewById<View>(R.id.root).applyDefaultImeWindowInsets()
|
|
147
|
+
|
|
148
|
+
splash = findViewById(R.id.splash)
|
|
149
|
+
showSplash()
|
|
150
|
+
|
|
151
|
+
initializeTabs(savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: 0)
|
|
152
|
+
|
|
153
|
+
observeEvents()
|
|
154
|
+
observePathConfiguration()
|
|
155
|
+
|
|
156
|
+
// Survives `recreate()` — the Intent does, fields don't. See
|
|
157
|
+
// [rebuildAndRoute].
|
|
158
|
+
intent.getStringExtra(EXTRA_ROUTE)?.let {
|
|
159
|
+
intent.removeExtra(EXTRA_ROUTE)
|
|
160
|
+
// Through route() rather than straight into pendingRoute, so a
|
|
161
|
+
// landing page that happens to be an overflow tab still selects the
|
|
162
|
+
// More host on the way in.
|
|
163
|
+
route(it)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A deep link that launched the app, routed once a navigator exists.
|
|
167
|
+
handleIntent(intent)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override fun onSaveInstanceState(outState: Bundle) {
|
|
171
|
+
super.onSaveInstanceState(outState)
|
|
172
|
+
// Saved rather than kept in a field precisely because of the
|
|
173
|
+
// `recreate()` a tab-path change forces: the user should come back to
|
|
174
|
+
// the tab they were on.
|
|
175
|
+
outState.putInt(STATE_SELECTED_TAB, bottomNav.selectedItemId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Refetch the server path configuration on every foreground, so a config
|
|
180
|
+
* change lands without a relaunch. Server-only: it is the auth-aware source
|
|
181
|
+
* of truth, and skipping the bundled copy avoids a flash of the baked
|
|
182
|
+
* (all-tabs) config before the server's answer arrives.
|
|
183
|
+
*/
|
|
184
|
+
override fun onResume() {
|
|
185
|
+
super.onResume()
|
|
186
|
+
reloadPathConfiguration()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `launchMode="singleTask"`, so deep links and auth callbacks arrive here. */
|
|
190
|
+
override fun onNewIntent(intent: Intent) {
|
|
191
|
+
super.onNewIntent(intent)
|
|
192
|
+
setIntent(intent)
|
|
193
|
+
handleIntent(intent)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
override fun onDestroy() {
|
|
197
|
+
// A BottomSheetDialog holds a window on this Activity, and the shell
|
|
198
|
+
// calls `recreate()` for its own reasons (a tab-path change, an instance
|
|
199
|
+
// switch). One left open across that is a leaked window.
|
|
200
|
+
dismissMoreSheet()
|
|
201
|
+
super.onDestroy()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override fun navigatorConfigurations(): List<NavigatorConfiguration> = pooledConfigurations
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A host finished building its start destination. Used to flush a route
|
|
208
|
+
* captured before any navigator existed — at `onCreate` there is nothing to
|
|
209
|
+
* route through yet.
|
|
210
|
+
*/
|
|
211
|
+
override fun onNavigatorReady(navigator: Navigator) {
|
|
212
|
+
super.onNavigatorReady(navigator)
|
|
213
|
+
|
|
214
|
+
val route = pendingRoute ?: return
|
|
215
|
+
// A route aimed at a specific host waits for that host. Only overflow
|
|
216
|
+
// routes are pinned this way, and only because the More host is lazy:
|
|
217
|
+
// it builds its navigator when the tab is first selected, which can be
|
|
218
|
+
// after another host has already reported in.
|
|
219
|
+
pendingRouteHostId?.let { if (navigator.configuration.navigatorHostId != it) return }
|
|
220
|
+
|
|
221
|
+
pendingRoute = null
|
|
222
|
+
pendingRouteHostId = null
|
|
223
|
+
navigator.route(route)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------------
|
|
227
|
+
// Tabs
|
|
228
|
+
// ------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
private fun initializeTabs(selectedIndex: Int) {
|
|
231
|
+
bottomNav = findViewById(R.id.bottom_nav)
|
|
232
|
+
|
|
233
|
+
tabController = HotwireBottomNavigationController(
|
|
234
|
+
activity = this,
|
|
235
|
+
view = bottomNav,
|
|
236
|
+
// Mandatory, not a preference. With eager loading all five pooled
|
|
237
|
+
// hosts cold-boot a WebView and fetch their start location at
|
|
238
|
+
// launch — including the hidden ones, whose URLs are exactly the
|
|
239
|
+
// auth-gated pages the config was hiding.
|
|
240
|
+
lazyLoadTabs = true
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
val arrangement = initialArrangement
|
|
244
|
+
loadedFingerprint = MainTabs.fingerprint(arrangement.entries)
|
|
245
|
+
loadedPathsFingerprint = MainTabs.pathsFingerprint(arrangement)
|
|
246
|
+
|
|
247
|
+
applyTabs(arrangement, selectedIndex)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The one place `load()` is ever called, because a second `load()` on a
|
|
252
|
+
* live controller has three side effects that all have to be undone in the
|
|
253
|
+
* same breath:
|
|
254
|
+
*
|
|
255
|
+
* 1. `loadMenu()` starts with `view.menu.clear()`, which drops every
|
|
256
|
+
* badge — and every icon, since it re-reads `iconResId`.
|
|
257
|
+
* 2. `menu.clear()` resets Material's `selectedItemId` to 0, so
|
|
258
|
+
* `selectTab(0)` takes the *reselect* path, which upstream wires to
|
|
259
|
+
* `navigator.clearAll()`. The user is silently returned to the tab's
|
|
260
|
+
* start location, and only when the selected tab is the first one —
|
|
261
|
+
* the default, so it is invisible in casual testing. Nulling the
|
|
262
|
+
* reselect listener first is enough; `load()` reinstalls it.
|
|
263
|
+
* 3. `selectTab()` runs through the still-installed item-selected
|
|
264
|
+
* listener, so the tab-selected callback fires for a selection the
|
|
265
|
+
* user never made.
|
|
266
|
+
*
|
|
267
|
+
* Fragments and WebViews are NOT recreated by this — that part of the cheap
|
|
268
|
+
* path holds.
|
|
269
|
+
*/
|
|
270
|
+
private fun applyTabs(arrangement: MainTabs.Arrangement, selectedIndex: Int) {
|
|
271
|
+
val tabs = MainTabs.tabs(config, arrangement)
|
|
272
|
+
currentArrangement = arrangement
|
|
273
|
+
|
|
274
|
+
// The sheet lists the arrangement it was opened against; a rebuild has
|
|
275
|
+
// just replaced that. Cheaper to close it than to reconcile it, and a
|
|
276
|
+
// tab bar changing underneath an open sheet is an auth change — the one
|
|
277
|
+
// moment the user should not be handed a stale list of destinations.
|
|
278
|
+
dismissMoreSheet()
|
|
279
|
+
|
|
280
|
+
// The item-SELECTED listener is nulled for the same reason the reselect
|
|
281
|
+
// one is: `load()` selects a tab while it runs, and a listener still
|
|
282
|
+
// installed from the previous arrangement would answer for a selection
|
|
283
|
+
// the user never made — including, with a stale More index, refusing it.
|
|
284
|
+
bottomNav.setOnItemSelectedListener(null)
|
|
285
|
+
bottomNav.setOnItemReselectedListener(null)
|
|
286
|
+
tabController.setOnTabSelectedListener(null)
|
|
287
|
+
|
|
288
|
+
tabController.load(tabs, selectedIndex)
|
|
289
|
+
|
|
290
|
+
applyTabIcons(arrangement)
|
|
291
|
+
applyTabBadges(arrangement)
|
|
292
|
+
// AFTER load(), never before: load() reinstalls upstream's own
|
|
293
|
+
// listeners over anything set here (that is the whole reason the
|
|
294
|
+
// reselect listener has to be nulled first), so ours have to go on last
|
|
295
|
+
// or the More tap does nothing.
|
|
296
|
+
installTabListeners(arrangement)
|
|
297
|
+
|
|
298
|
+
// Nothing to switch between means no tab bar. The first host is still
|
|
299
|
+
// the current navigator and shows the app's start URL, which is the
|
|
300
|
+
// single-navigator shape iOS falls back to.
|
|
301
|
+
tabController.visibility = if (arrangement.slots.isEmpty()) {
|
|
302
|
+
HotwireBottomNavigationController.Visibility.HIDDEN
|
|
303
|
+
} else {
|
|
304
|
+
HotwireBottomNavigationController.Visibility.DEFAULT
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `HotwireBottomTab.iconResId` is an `@DrawableRes Int` — there is no
|
|
310
|
+
* Drawable or Bitmap overload — so a font-rendered icon can only be applied
|
|
311
|
+
* by overwriting the menu item afterwards. Every tab is therefore built
|
|
312
|
+
* with `ic_tab_placeholder` and corrected here, after every `load()`.
|
|
313
|
+
*/
|
|
314
|
+
private fun applyTabIcons(arrangement: MainTabs.Arrangement) {
|
|
315
|
+
val sizePx = (ICON_SIZE_DP * resources.displayMetrics.density).toInt()
|
|
316
|
+
|
|
317
|
+
// Over the slots, not the entries: the More slot has an icon of its own
|
|
318
|
+
// (`more_horiz`) and no entry to take one from.
|
|
319
|
+
arrangement.slots.forEachIndexed { index, slot ->
|
|
320
|
+
bottomNav.menu.getItem(index).icon = IconFont.drawable(this, slot.icon, sizePx)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Overflow tabs have no bar item of their own, so their badges are summed
|
|
326
|
+
* onto More — the same aggregation iOS's More tab does. Every slot is
|
|
327
|
+
* re-derived from [tabBadges] rather than patched in place, because a badge
|
|
328
|
+
* on any one overflow tab changes the More total, and because [applyTabs]
|
|
329
|
+
* has to re-apply all of them after `load()` anyway.
|
|
330
|
+
*/
|
|
331
|
+
private fun applyTabBadges(arrangement: MainTabs.Arrangement) {
|
|
332
|
+
for (index in 0 until MainTabs.size) {
|
|
333
|
+
val count = when {
|
|
334
|
+
arrangement.isMoreIndex(index) ->
|
|
335
|
+
arrangement.overflow.sumOf { tabBadges[it.path] ?: 0 }
|
|
336
|
+
else -> arrangement.entryAt(index)?.let { tabBadges[it.path] } ?: 0
|
|
337
|
+
}
|
|
338
|
+
applyTabBadge(index, count)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private fun applyTabBadge(index: Int, count: Int) {
|
|
343
|
+
if (index < 0 || index >= bottomNav.menu.size()) return
|
|
344
|
+
|
|
345
|
+
if (count > 0) {
|
|
346
|
+
bottomNav.getOrCreateBadge(index).apply {
|
|
347
|
+
number = count
|
|
348
|
+
isVisible = true
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
bottomNav.removeBadge(index)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ------------------------------------------------------------------------
|
|
356
|
+
// The More tab
|
|
357
|
+
// ------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Both tab listeners, reinstalled after every `load()` — but only when there
|
|
361
|
+
* is a More slot. With five tabs or fewer, upstream's own listeners (which
|
|
362
|
+
* `load()` just installed) are exactly right and are left alone.
|
|
363
|
+
*
|
|
364
|
+
* **More is a menu, not a destination.** Tapping it must open the sheet and
|
|
365
|
+
* navigate nowhere: the user has not chosen anything yet, so nothing should
|
|
366
|
+
* load and the tab they were on should stay the tab they are on. Material
|
|
367
|
+
* selects an item the instant it is tapped, and the only hook early enough
|
|
368
|
+
* to stop that is the item-selected listener's return value — `false` means
|
|
369
|
+
* "I handled it, don't select it", and the previous item keeps the bar with
|
|
370
|
+
* no flicker. Waiting for the sheet's dismiss callback to put it back would
|
|
371
|
+
* show the user a round trip through the More state.
|
|
372
|
+
*
|
|
373
|
+
* Owning that listener means reimplementing what it displaces for the other
|
|
374
|
+
* tabs, which is [switchToSlot]. The same goes for reselection: upstream
|
|
375
|
+
* clears the reselected tab's backstack, right for a real tab and wrong for
|
|
376
|
+
* More, whose "content" is the sheet.
|
|
377
|
+
*
|
|
378
|
+
* Selection and reselection are separate callbacks, and More has to answer
|
|
379
|
+
* to both — the first tap after a row was chosen is a reselection, and only
|
|
380
|
+
* handling selection would make More a one-shot.
|
|
381
|
+
*/
|
|
382
|
+
private fun installTabListeners(arrangement: MainTabs.Arrangement) {
|
|
383
|
+
if (!arrangement.hasMore) return
|
|
384
|
+
|
|
385
|
+
bottomNav.setOnItemSelectedListener { item ->
|
|
386
|
+
val index = item.itemId
|
|
387
|
+
// selectingMoreForRoute is the one case where selecting More IS the
|
|
388
|
+
// intent: something routed to an overflow path and the host has to
|
|
389
|
+
// come forward to receive it.
|
|
390
|
+
if (arrangement.isMoreIndex(index) && !selectingMoreForRoute) {
|
|
391
|
+
showMoreSheet(arrangement)
|
|
392
|
+
false
|
|
393
|
+
} else {
|
|
394
|
+
switchToSlot(index)
|
|
395
|
+
true
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
bottomNav.setOnItemReselectedListener { item ->
|
|
400
|
+
if (arrangement.isMoreIndex(item.itemId)) {
|
|
401
|
+
showMoreSheet(arrangement)
|
|
402
|
+
} else {
|
|
403
|
+
// What upstream's listener would have done.
|
|
404
|
+
MainTabs.hostIds.getOrNull(item.itemId)?.let {
|
|
405
|
+
delegate.findNavigatorHost(it)?.navigator?.clearAll()
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Bring a slot's host forward — upstream `HotwireBottomNavigationController`'s
|
|
413
|
+
* private `switchTab`, which taking over the item-selected listener costs us.
|
|
414
|
+
* Both halves matter: `setCurrentNavigator` is what routes and the back
|
|
415
|
+
* button follow (and what lazily builds a host's graph the first time), and
|
|
416
|
+
* the visibility pass is what stops five stacked hosts from drawing at once.
|
|
417
|
+
*/
|
|
418
|
+
private fun switchToSlot(index: Int) {
|
|
419
|
+
val tabs = tabController.tabs
|
|
420
|
+
val selected = tabs.getOrNull(index) ?: return
|
|
421
|
+
|
|
422
|
+
delegate.setCurrentNavigator(selected.configuration)
|
|
423
|
+
tabs.forEach { tab ->
|
|
424
|
+
findViewById<View>(tab.configuration.navigatorHostId)?.isVisible = tab === selected
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The overflow tabs, as a bottom sheet — iOS gets this list free from
|
|
430
|
+
* UITabBarController and Android has no equivalent at all, so it is built by
|
|
431
|
+
* hand. Icons come from [IconFont], the shell's only icon path, so a name
|
|
432
|
+
* that renders on a tab renders here identically.
|
|
433
|
+
*
|
|
434
|
+
* Dismissing without choosing is a complete no-op by construction: the sheet
|
|
435
|
+
* changes nothing on its way up, so there is nothing to undo on its way
|
|
436
|
+
* down. Only a row does anything, and what it does is [route].
|
|
437
|
+
*/
|
|
438
|
+
private fun showMoreSheet(arrangement: MainTabs.Arrangement) {
|
|
439
|
+
// A programmatic selection en route to an overflow tab runs these same
|
|
440
|
+
// listeners. The user asked to go somewhere; don't answer with a menu.
|
|
441
|
+
if (selectingMoreForRoute) return
|
|
442
|
+
if (moreSheet?.isShowing == true) return
|
|
443
|
+
|
|
444
|
+
val inflater = LayoutInflater.from(this)
|
|
445
|
+
val root = inflater.inflate(R.layout.tab_more_sheet, null) as ViewGroup
|
|
446
|
+
val dialog = BottomSheetDialog(this)
|
|
447
|
+
|
|
448
|
+
root.findViewById<TextView>(R.id.tab_more_sheet_title).text = moreTitle
|
|
449
|
+
|
|
450
|
+
val sizePx = (ICON_SIZE_DP * resources.displayMetrics.density).toInt()
|
|
451
|
+
val tint = themeColor(CONTROL_ATTR, ICON_FALLBACK)
|
|
452
|
+
val list = root.findViewById<LinearLayout>(R.id.tab_more_sheet_items)
|
|
453
|
+
|
|
454
|
+
for (entry in arrangement.overflow) {
|
|
455
|
+
val row = inflater.inflate(R.layout.tab_more_sheet_item, list, false)
|
|
456
|
+
row.findViewById<TextView>(R.id.tab_more_sheet_item_title).text = entry.title
|
|
457
|
+
|
|
458
|
+
// IconFont draws opaque white and leaves colour to the host, so the
|
|
459
|
+
// tint is required, not decorative: untinted is invisible in light
|
|
460
|
+
// mode. (The tab bar gets this from its ColorStateList; a plain
|
|
461
|
+
// ImageView has none.)
|
|
462
|
+
row.findViewById<ImageView>(R.id.tab_more_sheet_item_icon).setImageDrawable(
|
|
463
|
+
DrawableCompat.wrap(IconFont.drawable(this, entry.icon, sizePx)).mutate()
|
|
464
|
+
.also { DrawableCompat.setTint(it, tint) }
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
row.setOnClickListener {
|
|
468
|
+
dialog.dismiss()
|
|
469
|
+
route(config.url(forPath = entry.path))
|
|
470
|
+
}
|
|
471
|
+
list.addView(row)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
dialog.setOnDismissListener { moreSheet = null }
|
|
475
|
+
dialog.setContentView(root)
|
|
476
|
+
moreSheet = dialog
|
|
477
|
+
dialog.show()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private fun dismissMoreSheet() {
|
|
481
|
+
moreSheet?.dismiss()
|
|
482
|
+
moreSheet = null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Claim a location for the More host when it is an overflow tab's path.
|
|
487
|
+
*
|
|
488
|
+
* Every other way into the app — a deep link, a universal link,
|
|
489
|
+
* `Everywhere.visit` — routes through the current navigator, which for an
|
|
490
|
+
* overflow tab is whatever tab the user happens to be standing in. That is
|
|
491
|
+
* how a destination with no bar item goes missing. Selecting the More host
|
|
492
|
+
* first is what gives it somewhere to land.
|
|
493
|
+
*
|
|
494
|
+
* Returns whether the selection happened, because the caller then has to
|
|
495
|
+
* wait a frame: selecting a lazy host is what builds its navigator.
|
|
496
|
+
*/
|
|
497
|
+
private fun selectMoreHostForOverflow(location: String): Boolean {
|
|
498
|
+
val arrangement = currentArrangement
|
|
499
|
+
if (!arrangement.hasMore) return false
|
|
500
|
+
|
|
501
|
+
val path = config.pathAndQuery(location)
|
|
502
|
+
if (arrangement.overflow.none { it.path == path }) return false
|
|
503
|
+
|
|
504
|
+
val index = arrangement.moreIndex
|
|
505
|
+
selectingMoreForRoute = true
|
|
506
|
+
try {
|
|
507
|
+
// Setting selectedItemId runs the item-selected (or, if More is
|
|
508
|
+
// already selected, the item-reselected) listener synchronously,
|
|
509
|
+
// which is exactly why the flag above wraps it.
|
|
510
|
+
bottomNav.selectedItemId = index
|
|
511
|
+
} finally {
|
|
512
|
+
selectingMoreForRoute = false
|
|
513
|
+
}
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private fun themeColor(attr: Int, fallback: Int): Int {
|
|
518
|
+
val value = TypedValue()
|
|
519
|
+
if (!theme.resolveAttribute(attr, value, true)) return fallback
|
|
520
|
+
return if (value.resourceId != 0) ContextCompat.getColor(this, value.resourceId) else value.data
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ------------------------------------------------------------------------
|
|
524
|
+
// Path configuration
|
|
525
|
+
// ------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The tabs are auth-gated server-side, so the shell reacts to the
|
|
529
|
+
* configuration landing rather than reading it once. `loadState` emits for
|
|
530
|
+
* each source (bundled, cached, remote); the fingerprints make the
|
|
531
|
+
* redundant emissions free.
|
|
532
|
+
*/
|
|
533
|
+
private fun observePathConfiguration() {
|
|
534
|
+
lifecycleScope.launch {
|
|
535
|
+
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
536
|
+
Hotwire.config.pathConfiguration.loadState.collect { state ->
|
|
537
|
+
if (state is PathConfigurationLoadState.Loaded) applyPathConfiguration()
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private fun applyPathConfiguration() {
|
|
544
|
+
val entries = MainTabs.entries()
|
|
545
|
+
val fingerprint = MainTabs.fingerprint(entries)
|
|
546
|
+
if (fingerprint == loadedFingerprint) return
|
|
547
|
+
|
|
548
|
+
val arrangement = MainTabs.arrange(entries, moreTitle)
|
|
549
|
+
|
|
550
|
+
if (MainTabs.pathsFingerprint(arrangement) != loadedPathsFingerprint) {
|
|
551
|
+
// A tab's start location changed, so its NavigatorHost was built
|
|
552
|
+
// against the wrong URL — and the host resolved that configuration
|
|
553
|
+
// once, in onCreate, then cached it inside its Navigator. Nothing
|
|
554
|
+
// short of rebuilding the Activity re-reads it. The selected tab
|
|
555
|
+
// survives through onSaveInstanceState.
|
|
556
|
+
//
|
|
557
|
+
// Guarded, because `recreate()` here is a loop waiting to happen:
|
|
558
|
+
// every rebuild re-runs onCreate, which re-reads the configuration
|
|
559
|
+
// and can land right back in this branch. When it does, the Activity
|
|
560
|
+
// is destroyed and rebuilt faster than it can draw, input dispatch
|
|
561
|
+
// never gets a turn, and Android kills the app with an ANR — a hard
|
|
562
|
+
// freeze that looks nothing like a config problem from the outside.
|
|
563
|
+
//
|
|
564
|
+
// The wall clock is the only honest signal: fields die with the
|
|
565
|
+
// Activity, so the timestamp lives on the Intent, which survives.
|
|
566
|
+
val now = SystemClock.elapsedRealtime()
|
|
567
|
+
val since = now - intent.getLongExtra(EXTRA_LAST_REBUILD, 0L)
|
|
568
|
+
if (since < REBUILD_DEBOUNCE_MS) {
|
|
569
|
+
// Take the cheap path instead. Tab paths will be stale until the
|
|
570
|
+
// next clean rebuild, but a slightly wrong tab bar is a far
|
|
571
|
+
// better outcome than an app the user has to force-quit.
|
|
572
|
+
Log.w(TAG, "tab paths changed again after ${since}ms — skipping rebuild to avoid " +
|
|
573
|
+
"a recreate loop; tab start locations may be stale")
|
|
574
|
+
loadedFingerprint = fingerprint
|
|
575
|
+
loadedPathsFingerprint = MainTabs.pathsFingerprint(arrangement)
|
|
576
|
+
applyTabs(arrangement, safeSelectedIndex(arrangement))
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
Log.d(TAG, "tab paths changed — rebuilding the activity")
|
|
581
|
+
intent.putExtra(EXTRA_LAST_REBUILD, now)
|
|
582
|
+
recreate()
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
loadedFingerprint = fingerprint
|
|
587
|
+
applyTabs(arrangement, safeSelectedIndex(arrangement))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The selected tab, clamped into the list we are about to load.
|
|
592
|
+
*
|
|
593
|
+
* `selectedItemId` doubles as an index only because upstream builds the menu
|
|
594
|
+
* with `menu.add(0, index, index, …)` — and upstream then does `tabs[itemId]`
|
|
595
|
+
* on every selection. So when a tab set SHRINKS (signing out drops the
|
|
596
|
+
* auth-gated tabs), a selection left pointing past the new end is an
|
|
597
|
+
* IndexOutOfBounds waiting for the next tap, inside framework code we don't
|
|
598
|
+
* own. Clamping here is the whole fix.
|
|
599
|
+
*
|
|
600
|
+
* Clamped to the SLOTS, which is what the bar actually shows: eleven tabs
|
|
601
|
+
* still only ever occupy five, so the entry count is not the bound.
|
|
602
|
+
*/
|
|
603
|
+
private fun safeSelectedIndex(arrangement: MainTabs.Arrangement): Int =
|
|
604
|
+
bottomNav.selectedItemId.coerceIn(0, (arrangement.slots.size - 1).coerceAtLeast(0))
|
|
605
|
+
|
|
606
|
+
private fun reloadPathConfiguration(serverOnly: Boolean = true) {
|
|
607
|
+
val remoteUrl = config.pathConfigurationUrl
|
|
608
|
+
|
|
609
|
+
Hotwire.loadPathConfiguration(
|
|
610
|
+
context = this,
|
|
611
|
+
location = PathConfiguration.Location(
|
|
612
|
+
// Server-only after launch. The bundled copy is the baked,
|
|
613
|
+
// all-tabs document; re-reading it would flash tabs a
|
|
614
|
+
// signed-out user isn't entitled to before the server says so.
|
|
615
|
+
assetFilePath = if (serverOnly) null else BUNDLED_PATH_CONFIGURATION,
|
|
616
|
+
remoteFileUrl = remoteUrl
|
|
617
|
+
),
|
|
618
|
+
options = PathConfiguration.LoaderOptions(httpHeaders = cookieHeaders(remoteUrl))
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The tab list is auth-gated and the framework fetches the configuration
|
|
624
|
+
* with OkHttp — which has no cookie jar and knows nothing about the
|
|
625
|
+
* WebView's. Without mirroring the session cookie across, the config
|
|
626
|
+
* request is anonymous and a signed-in user gets a signed-out tab bar.
|
|
627
|
+
*
|
|
628
|
+
* (iOS needs a whole `getAllCookies` round trip and a full re-sync for
|
|
629
|
+
* this. Android's CookieManager is one process-wide store, so it is one
|
|
630
|
+
* synchronous call.)
|
|
631
|
+
*/
|
|
632
|
+
private fun cookieHeaders(url: String): Map<String, String> {
|
|
633
|
+
val cookie = runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
|
|
634
|
+
return if (cookie.isNullOrEmpty()) emptyMap() else mapOf("Cookie" to cookie)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ------------------------------------------------------------------------
|
|
638
|
+
// Page → shell control messages
|
|
639
|
+
// ------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
private fun observeEvents() {
|
|
642
|
+
lifecycleScope.launch {
|
|
643
|
+
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
644
|
+
EverywhereEvents.events.collect { handle(it) }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private fun handle(event: EverywhereEvents.Event) {
|
|
650
|
+
when (event) {
|
|
651
|
+
is EverywhereEvents.Event.ReloadConfig -> reloadPathConfiguration()
|
|
652
|
+
is EverywhereEvents.Event.ResetApp -> resetApp(resetTarget(event.to))
|
|
653
|
+
is EverywhereEvents.Event.SetTabBadge -> setTabBadge(event.path, event.count)
|
|
654
|
+
is EverywhereEvents.Event.SetBadge -> setAppBadge(event.count)
|
|
655
|
+
is EverywhereEvents.Event.SetInstance -> setInstance(event.url, event.to)
|
|
656
|
+
is EverywhereEvents.Event.ClearInstance -> clearInstance(event.to)
|
|
657
|
+
is EverywhereEvents.Event.NativeVisit -> nativeVisit(event.path)
|
|
658
|
+
is EverywhereEvents.Event.AuthFlow -> startAuthFlow(event.to)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private fun setTabBadge(path: String, count: Int) {
|
|
663
|
+
if (count > 0) tabBadges[path] = count else tabBadges.remove(path)
|
|
664
|
+
|
|
665
|
+
// Every slot re-derived rather than the one that matched: a badge on an
|
|
666
|
+
// overflow tab lands on More as part of a sum, and a sum can only be
|
|
667
|
+
// recomputed, not patched.
|
|
668
|
+
applyTabBadges(currentArrangement)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* There is no Android equivalent of iOS's `setBadgeCount`. Launcher badges
|
|
673
|
+
* are derived from active notifications, and every "badge count" library is
|
|
674
|
+
* a per-OEM broadcast the AOSP launcher ignores. Logged rather than faked:
|
|
675
|
+
* the page's call is not an error, it simply has nowhere to land here.
|
|
676
|
+
*/
|
|
677
|
+
private fun setAppBadge(count: Int) {
|
|
678
|
+
Log.d(TAG, "setBadge($count): Android has no launcher badge API; ignoring")
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* `everywhereVisit(path)` from extension code: routed through the active
|
|
683
|
+
* navigator so the visit gets the full path-configuration treatment
|
|
684
|
+
* (modals, native screens) like any web-driven visit.
|
|
685
|
+
*/
|
|
686
|
+
private fun nativeVisit(path: String) {
|
|
687
|
+
val location = if (path.startsWith("/")) config.url(forPath = path) else path
|
|
688
|
+
route(location)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Every location the shell routes itself passes through here: deep links,
|
|
693
|
+
* universal links, `Everywhere.visit`, the reset landing page, and a pick
|
|
694
|
+
* from the More sheet.
|
|
695
|
+
*
|
|
696
|
+
* An overflow tab is claimed by the More host first. That selection is what
|
|
697
|
+
* builds the host's navigator (the pool is lazy), so delivery waits a frame
|
|
698
|
+
* — and if the navigator still isn't ready, [pendingRoute] carries it,
|
|
699
|
+
* pinned to that host so no other one answers for it.
|
|
700
|
+
*/
|
|
701
|
+
private fun route(location: String) {
|
|
702
|
+
if (selectMoreHostForOverflow(location)) {
|
|
703
|
+
val hostId = MainTabs.hostIds[currentArrangement.moreIndex]
|
|
704
|
+
bottomNav.post { deliver(location, hostId) }
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
deliver(location, hostId = null)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private fun deliver(location: String, hostId: Int?) {
|
|
712
|
+
val navigator = delegate.currentNavigator
|
|
713
|
+
if (navigator != null) {
|
|
714
|
+
navigator.route(location)
|
|
715
|
+
} else {
|
|
716
|
+
pendingRoute = location
|
|
717
|
+
pendingRouteHostId = hostId
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ------------------------------------------------------------------------
|
|
722
|
+
// Reset (auth changes) and instance switching
|
|
723
|
+
// ------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
private fun resetTarget(path: String?): String =
|
|
726
|
+
path?.let { config.url(forPath = it) } ?: config.startUrl
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Full app reset for an auth change. Deterministic and self-contained — it
|
|
730
|
+
* does NOT depend on the reset page's JavaScript running:
|
|
731
|
+
* 1. abandon any sign-in still in flight; it is answering a question the
|
|
732
|
+
* app no longer has,
|
|
733
|
+
* 2. clear cached web content, so no page is served from its pre-auth
|
|
734
|
+
* state,
|
|
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.
|
|
739
|
+
*/
|
|
740
|
+
private fun resetApp(target: String) {
|
|
741
|
+
AuthFlow.cancel()
|
|
742
|
+
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
|
+
|
|
751
|
+
reloadPathConfiguration()
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* `Everywhere.instance.set(url)`: persist the picked instance as the app's
|
|
756
|
+
* root, then rebuild onto it. Ignored unless everywhere.yml opts in
|
|
757
|
+
* (`remote.instances: true`) — for every other app, a page that posts
|
|
758
|
+
* setInstance must not be able to re-root the shell.
|
|
759
|
+
*
|
|
760
|
+
* A reset is not enough here, unlike iOS: every pooled host's
|
|
761
|
+
* `startLocation` is an absolute URL built against the OLD root and cached
|
|
762
|
+
* in its Navigator, so the Activity has to go.
|
|
763
|
+
*/
|
|
764
|
+
private fun setInstance(url: String, to: String?) {
|
|
765
|
+
if (!config.remoteInstances) return
|
|
766
|
+
if (!config.setInstanceUrl(url)) return
|
|
767
|
+
|
|
768
|
+
rebuildAndRoute(resetTarget(to))
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** `Everywhere.instance.clear()`: back to the stamped root — the picker. */
|
|
772
|
+
private fun clearInstance(to: String?) {
|
|
773
|
+
if (!config.remoteInstances) return
|
|
774
|
+
|
|
775
|
+
config.setInstanceUrl(null)
|
|
776
|
+
rebuildAndRoute(resetTarget(to))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* `recreate()` loses every field, so the landing page rides along on the
|
|
781
|
+
* Intent — which `recreate()` does preserve — and is picked up by the next
|
|
782
|
+
* `onCreate`.
|
|
783
|
+
*/
|
|
784
|
+
private fun rebuildAndRoute(target: String) {
|
|
785
|
+
AuthFlow.cancel()
|
|
786
|
+
clearWebContentCache()
|
|
787
|
+
intent.putExtra(EXTRA_ROUTE, target)
|
|
788
|
+
recreate()
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Drop web storage so a page isn't served from its pre-auth state. Cookies
|
|
793
|
+
* are left intact — the server has already invalidated the session on
|
|
794
|
+
* sign-out, and the fresh cookie has to survive a sign-in.
|
|
795
|
+
*
|
|
796
|
+
* Slightly broader than iOS, which drops only the HTTP and offline caches:
|
|
797
|
+
* Android's per-WebView `clearCache` needs a live WebView, while
|
|
798
|
+
* `WebStorage` is process-wide. Both err in the same direction — dropping
|
|
799
|
+
* state that belonged to the previous user.
|
|
800
|
+
*/
|
|
801
|
+
private fun clearWebContentCache() {
|
|
802
|
+
WebStorage.getInstance().deleteAllData()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ------------------------------------------------------------------------
|
|
806
|
+
// Deep links and the sign-in callback
|
|
807
|
+
// ------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
private fun handleIntent(intent: Intent?) {
|
|
810
|
+
val data: Uri = intent?.data ?: return
|
|
811
|
+
|
|
812
|
+
// The Custom Tab handed the token back through the app's own scheme.
|
|
813
|
+
// Checked first and unconditionally: after a process death there is no
|
|
814
|
+
// pending flow, but the token is still good and the user still expects
|
|
815
|
+
// to end up signed in.
|
|
816
|
+
if (AuthFlow.handleCallback(data)) return
|
|
817
|
+
|
|
818
|
+
val url = data.toString()
|
|
819
|
+
if (!config.handlesUniversalLink(url)) return
|
|
820
|
+
|
|
821
|
+
// Re-rooted through the app's own effective root: a link to the
|
|
822
|
+
// production host must still open the dev server, or the picked
|
|
823
|
+
// instance, when that is what the app is running against.
|
|
824
|
+
route(config.url(forPath = config.pathAndQuery(url)))
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ------------------------------------------------------------------------
|
|
828
|
+
// Third-party sign-in
|
|
829
|
+
// ------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Open a provider sign-in in a Custom Tab. Nothing is loaded in the web
|
|
833
|
+
* view: the whole point is that this URL never touches it — Google rejects
|
|
834
|
+
* OAuth from an embedded WebView outright (`disallowed_useragent`), and the
|
|
835
|
+
* rest degrade badly there.
|
|
836
|
+
*/
|
|
837
|
+
private fun startAuthFlow(to: String) {
|
|
838
|
+
// Belt and braces: the control channel already rejects a `to` that
|
|
839
|
+
// isn't an absolute same-origin path, but this is the one message that
|
|
840
|
+
// sends the user out to a browser, so it is re-checked here.
|
|
841
|
+
if (!to.startsWith("/") || to.startsWith("//")) return
|
|
842
|
+
|
|
843
|
+
val scheme = config.authScheme ?: return
|
|
844
|
+
val start = config.authStartUrl(to = to, returnTo = currentPath())
|
|
845
|
+
|
|
846
|
+
AuthFlow.start(this, start, scheme) { result ->
|
|
847
|
+
when (result) {
|
|
848
|
+
is AuthFlow.Result.Success -> finishAuthFlow(result.token)
|
|
849
|
+
// They closed the tab; they are still on the page they started
|
|
850
|
+
// from, which is where they belong.
|
|
851
|
+
is AuthFlow.Result.Canceled -> Unit
|
|
852
|
+
is AuthFlow.Result.Failed -> presentAuthFailure(result.message)
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Spend the handoff token in the web view and rebuild around whatever it
|
|
859
|
+
* signs the app in as.
|
|
860
|
+
*
|
|
861
|
+
* The token exists because the Custom Tab's cookie jar is not the WebView's
|
|
862
|
+
* and is unreachable from here: the gem seals the browser session into a
|
|
863
|
+
* one-time token, and visiting the handoff endpoint IN THE WEB VIEW is what
|
|
864
|
+
* puts the resulting Set-Cookie in the jar that matters. Only once that
|
|
865
|
+
* request has finished can the auth-gated config be refetched — earlier and
|
|
866
|
+
* the fetch races the cookie.
|
|
867
|
+
*/
|
|
868
|
+
private fun finishAuthFlow(token: String) {
|
|
869
|
+
clearWebContentCache()
|
|
870
|
+
awaitingAuthHandoff = true
|
|
871
|
+
route(config.authHandoffUrl(token))
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private fun presentAuthFailure(message: String) {
|
|
875
|
+
Log.e(TAG, "authFlow failed: $message")
|
|
876
|
+
|
|
877
|
+
AlertDialog.Builder(this)
|
|
878
|
+
.setTitle(R.string.everywhere_auth_failed_title)
|
|
879
|
+
.setMessage(R.string.everywhere_auth_failed_message)
|
|
880
|
+
.setPositiveButton(R.string.everywhere_ok, null)
|
|
881
|
+
.show()
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Where the user is, for the flow to return to if the app doesn't say. */
|
|
885
|
+
private fun currentPath(): String? =
|
|
886
|
+
delegate.currentNavigator?.location?.let { config.pathAndQuery(it) }
|
|
887
|
+
|
|
888
|
+
// ------------------------------------------------------------------------
|
|
889
|
+
// Splash
|
|
890
|
+
// ------------------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Continues the themed launch background until the first request finishes,
|
|
894
|
+
* so there is no blank frame while the first page loads over the network.
|
|
895
|
+
* An app-provided splash (everywhere.yml `native.android.splash`) replaces
|
|
896
|
+
* the default spinner wholesale.
|
|
897
|
+
*/
|
|
898
|
+
private fun showSplash() {
|
|
899
|
+
splashShownAt = System.currentTimeMillis()
|
|
900
|
+
|
|
901
|
+
EverywhereExtensions.splashView(this)?.let { view ->
|
|
902
|
+
splashIsCustom = true
|
|
903
|
+
splash.removeAllViews()
|
|
904
|
+
splash.addView(view)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Safety net: never trap the user behind the splash.
|
|
908
|
+
splash.postDelayed({ dismissSplash() }, SPLASH_TIMEOUT_MS)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Called by [WebFragment] when a visit finishes or fails — as close as this
|
|
913
|
+
* platform gets to iOS's `requestDidFinish`.
|
|
914
|
+
*/
|
|
915
|
+
fun onWebContentSettled() {
|
|
916
|
+
dismissSplash()
|
|
917
|
+
|
|
918
|
+
// The handoff has landed, so its cookies are in the WebView's jar: now
|
|
919
|
+
// the tab list can be fetched as the signed-in user.
|
|
920
|
+
if (awaitingAuthHandoff) {
|
|
921
|
+
awaitingAuthHandoff = false
|
|
922
|
+
reloadPathConfiguration()
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private fun dismissSplash() {
|
|
927
|
+
if (!splash.isVisible) return
|
|
928
|
+
|
|
929
|
+
// A branded splash that flashes for 50 ms reads as a glitch. Against a
|
|
930
|
+
// local dev server the first request finishes before the first frame is
|
|
931
|
+
// presented, so a custom splash is held to its minimum — while the
|
|
932
|
+
// default spinner is allowed to vanish instantly.
|
|
933
|
+
if (splashIsCustom) {
|
|
934
|
+
val remaining = config.splashMinimumDisplay - (System.currentTimeMillis() - splashShownAt)
|
|
935
|
+
if (remaining > 0) {
|
|
936
|
+
if (splashDismissScheduled) return
|
|
937
|
+
splashDismissScheduled = true
|
|
938
|
+
splash.postDelayed({
|
|
939
|
+
splashDismissScheduled = false
|
|
940
|
+
dismissSplash()
|
|
941
|
+
}, remaining)
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
splash.animate()
|
|
947
|
+
.alpha(0f)
|
|
948
|
+
.setDuration(SPLASH_FADE_MS)
|
|
949
|
+
.withEndAction { splash.isVisible = false }
|
|
950
|
+
.start()
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private companion object {
|
|
954
|
+
const val TAG = "Everywhere"
|
|
955
|
+
const val STATE_SELECTED_TAB = "everywhere.selectedTab"
|
|
956
|
+
const val EXTRA_ROUTE = "everywhere.route"
|
|
957
|
+
const val EXTRA_LAST_REBUILD = "everywhere.lastRebuild"
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* How close two path-change rebuilds have to be before the second is
|
|
961
|
+
* treated as a loop rather than a legitimate config change. Generous
|
|
962
|
+
* enough to cover a slow cold start, far below the 5s input-dispatch
|
|
963
|
+
* timeout that turns a loop into an ANR.
|
|
964
|
+
*/
|
|
965
|
+
const val REBUILD_DEBOUNCE_MS = 3_000L
|
|
966
|
+
const val BUNDLED_PATH_CONFIGURATION = "json/path-configuration.json"
|
|
967
|
+
const val ICON_SIZE_DP = 24
|
|
968
|
+
const val SPLASH_TIMEOUT_MS = 8_000L
|
|
969
|
+
const val SPLASH_FADE_MS = 250L
|
|
970
|
+
|
|
971
|
+
/** Tint for the More sheet's icons; see MenuComponent for the same pair. */
|
|
972
|
+
val CONTROL_ATTR = androidx.appcompat.R.attr.colorControlNormal
|
|
973
|
+
const val ICON_FALLBACK = 0xFF444444.toInt()
|
|
974
|
+
}
|
|
975
|
+
}
|