ruby_everywhere 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,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
+ }