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,201 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import dev.hotwire.core.config.Hotwire
4
+ import dev.hotwire.navigation.navigator.NavigatorConfiguration
5
+ import dev.hotwire.navigation.tabs.HotwireBottomTab
6
+
7
+ /**
8
+ * Maps the server's `settings.tabs` onto the fixed pool of NavigatorHosts in
9
+ * activity_main.xml.
10
+ *
11
+ * iOS builds its tab bar from the path configuration at runtime, so tabs can be
12
+ * auth-gated server-side and change without a release. Android can't do that
13
+ * directly — a tab needs a real FragmentContainerView id and NavigatorHost
14
+ * resolves its configuration once, in onCreate — so the shell declares five
15
+ * hosts up front and maps whatever the server sends onto them.
16
+ *
17
+ * The list this produces is ALWAYS five long. That is not tidiness, it is a
18
+ * hard requirement of the framework, twice over:
19
+ *
20
+ * * [NavigatorHost] throws `IllegalStateException("No configuration found for
21
+ * NavigatorHost")` in onCreate for any host in the layout that
22
+ * `navigatorConfigurations()` doesn't cover. Three tabs and five hosts
23
+ * would be two crashes at launch.
24
+ * * `HotwireBottomNavigationController.switchTab` only hides hosts present in
25
+ * the *new* tabs list, so a host dropped from a shortened list is never
26
+ * hidden and stays on screen, overlapping the tab that replaced it.
27
+ *
28
+ * "Not a tab right now" is therefore always `isVisible = false`, never a
29
+ * shorter list.
30
+ */
31
+ object MainTabs {
32
+
33
+ /**
34
+ * The pool, in layout order. Slot 0 is special:
35
+ * `HotwireActivityDelegate` initialises `currentNavigatorHostId` to the
36
+ * first configuration's host and loads it eagerly regardless of laziness,
37
+ * so slot 0 must always be a real tab (it is — the first resolved tab fills
38
+ * it, and with no tabs at all it holds the app's start URL).
39
+ */
40
+ val hostIds = listOf(
41
+ R.id.navigator_host_1,
42
+ R.id.navigator_host_2,
43
+ R.id.navigator_host_3,
44
+ R.id.navigator_host_4,
45
+ R.id.navigator_host_5
46
+ )
47
+
48
+ val size = hostIds.size
49
+
50
+ /** The Material icon the More slot wears. Verified present in both fonts. */
51
+ const val MORE_ICON = "more_horiz"
52
+
53
+ /** One tab as the server described it. `icon` is a Material icon name. */
54
+ data class Entry(val title: String, val path: String, val icon: String)
55
+
56
+ /**
57
+ * One occupied slot in the bar. [entry] is the tab it stands for, or `null`
58
+ * for the synthesised More slot, which stands for all of [Arrangement.overflow]
59
+ * at once and therefore for no single tab.
60
+ */
61
+ data class Slot(val title: String, val icon: String, val startPath: String, val entry: Entry?)
62
+
63
+ /**
64
+ * How the resolved tabs were laid out across the five hosts.
65
+ *
66
+ * Android's BottomNavigationView caps at five destinations and, unlike
67
+ * UITabBarController, synthesises nothing past that — a sixth tab simply
68
+ * has no bar item to live in. So the shell hand-builds iOS's answer: with
69
+ * six or more tabs the last slot becomes "More", a container that holds
70
+ * every tab from the fifth on and presents them in a bottom sheet.
71
+ *
72
+ * Five or fewer tabs arrange exactly as they always did. There is no More
73
+ * slot, no sheet, and nothing about the bar changes.
74
+ */
75
+ data class Arrangement(
76
+ val entries: List<Entry>,
77
+ val slots: List<Slot>,
78
+ val overflow: List<Entry>
79
+ ) {
80
+ val hasMore: Boolean get() = overflow.isNotEmpty()
81
+
82
+ /** The More slot's index, or -1 when there is no More slot. */
83
+ val moreIndex: Int get() = if (hasMore) slots.lastIndex else -1
84
+
85
+ fun isMoreIndex(index: Int): Boolean = hasMore && index == slots.lastIndex
86
+
87
+ /** The tab a slot stands for; `null` for More, and for empty slots. */
88
+ fun entryAt(index: Int): Entry? = slots.getOrNull(index)?.entry
89
+ }
90
+
91
+ /**
92
+ * `settings.tabs` from the merged path configuration. Parsed defensively:
93
+ * the settings map comes from Gson as plain collections, and the document
94
+ * is fetched from a server that can deploy independently of the app.
95
+ *
96
+ * Deliberately NOT truncated to the host pool. Everything past the fourth
97
+ * tab is still a tab — [arrange] puts it behind More rather than on the
98
+ * floor.
99
+ */
100
+ fun entries(): List<Entry> {
101
+ val tabs = Hotwire.config.pathConfiguration.settings["tabs"] as? List<*> ?: return emptyList()
102
+
103
+ return tabs.mapNotNull { entry ->
104
+ val map = entry as? Map<*, *> ?: return@mapNotNull null
105
+ val title = map["title"] as? String ?: return@mapNotNull null
106
+ val path = map["path"] as? String ?: return@mapNotNull null
107
+ Entry(title = title, path = path, icon = map["icon"] as? String ?: "circle")
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Lay the resolved tabs out across the pool.
113
+ *
114
+ * Up to five tabs each take a slot of their own. From six, slots 1–4 are the
115
+ * first four tabs and slot 5 becomes More, holding the rest.
116
+ *
117
+ * The More slot's host still needs a start location — every host in the
118
+ * pool does — and it gets the FIRST overflow tab's path. That is load
119
+ * bearing rather than arbitrary: it makes the fifth host's start location
120
+ * identical whether there are exactly five tabs or six, so gaining or
121
+ * losing a More slot never moves a host's start URL and never, by itself,
122
+ * trips the `recreate()` that a path change costs (see [pathsFingerprint]).
123
+ *
124
+ * Nothing fetches it until the user picks a row: More is a menu, not a
125
+ * destination, so tapping it never selects the host (MainActivity's
126
+ * item-selected listener refuses the selection) and `lazyLoadTabs = true`
127
+ * means an unselected host loads nothing.
128
+ */
129
+ fun arrange(entries: List<Entry>, moreTitle: String): Arrangement {
130
+ if (entries.size <= size) {
131
+ return Arrangement(
132
+ entries = entries,
133
+ slots = entries.map { Slot(it.title, it.icon, it.path, it) },
134
+ overflow = emptyList()
135
+ )
136
+ }
137
+
138
+ val kept = entries.take(size - 1)
139
+ val overflow = entries.drop(size - 1)
140
+
141
+ return Arrangement(
142
+ entries = entries,
143
+ slots = kept.map { Slot(it.title, it.icon, it.path, it) } +
144
+ Slot(moreTitle, MORE_ICON, overflow.first().path, null),
145
+ overflow = overflow
146
+ )
147
+ }
148
+
149
+ /**
150
+ * The five tabs, padded with hidden slots. Hidden slots still need a valid
151
+ * start location — it is what [dev.hotwire.navigation.routing.AppNavigationRouteDecisionHandler]
152
+ * compares hosts against — so they get the app's own start URL. They never
153
+ * fetch it: `lazyLoadTabs = true` means an unselected host loads nothing,
154
+ * and a hidden tab cannot be selected.
155
+ */
156
+ fun tabs(config: EverywhereConfig, arrangement: Arrangement): List<HotwireBottomTab> =
157
+ hostIds.mapIndexed { index, hostId ->
158
+ val slot = arrangement.slots.getOrNull(index)
159
+ HotwireBottomTab(
160
+ title = slot?.title.orEmpty(),
161
+ // A placeholder, always. `iconResId` is an @DrawableRes Int, so
162
+ // the font-rendered bitmap cannot be passed here; MainActivity
163
+ // overwrites the menu item's icon after every load().
164
+ iconResId = R.drawable.ic_tab_placeholder,
165
+ isVisible = slot != null,
166
+ configuration = NavigatorConfiguration(
167
+ // Stable per slot, not per path: the name keys the tab's
168
+ // Session and SessionViewModel, and a tab that merely
169
+ // changed title must not lose them.
170
+ name = "tab-${index + 1}",
171
+ navigatorHostId = hostId,
172
+ startLocation = slot?.let { config.url(forPath = it.startPath) } ?: config.startUrl
173
+ )
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Identifies a tab set for the cheap-vs-expensive decision.
179
+ *
180
+ * A change in title, icon or visibility is grade one: a second
181
+ * `load()` on the live controller, no fragment teardown, ~96 ms. A change
182
+ * in *paths* is grade two: `NavigatorHost` cached its configuration in
183
+ * onCreate, so nothing short of `Activity.recreate()` re-reads it. Hence
184
+ * two fingerprints — the paths one decides whether the Activity has to go.
185
+ *
186
+ * [fingerprint] covers every entry, overflow included: an overflow tab that
187
+ * changed its title or icon changes nothing in the bar, but the sheet has
188
+ * to be rebuilt from the new entries the next time it opens.
189
+ */
190
+ fun fingerprint(entries: List<Entry>): String =
191
+ entries.joinToString("\n") { "${it.title}|${it.path}|${it.icon}" }
192
+
193
+ /**
194
+ * The paths the five hosts were actually built against — the slots', not the
195
+ * entries'. An overflow tab's path is deliberately absent: it is not any
196
+ * host's start location, so changing it costs nothing and must not buy a
197
+ * `recreate()`.
198
+ */
199
+ fun pathsFingerprint(arrangement: Arrangement): String =
200
+ arrangement.slots.joinToString("\n") { it.startPath }
201
+ }
@@ -0,0 +1,13 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
4
+ import dev.hotwire.navigation.fragments.HotwireWebBottomSheetFragment
5
+
6
+ /**
7
+ * The destination for path rules with `context: modal` and a bottom-sheet
8
+ * presentation. Registered so the path configuration can name it; it needs no
9
+ * behaviour of its own — a sheet is never the first content, so it has no part
10
+ * in dismissing the splash.
11
+ */
12
+ @HotwireDestinationDeepLink(uri = "hotwire://fragment/web/modal/sheet")
13
+ class WebBottomSheetFragment : HotwireWebBottomSheetFragment()
@@ -0,0 +1,36 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import dev.hotwire.core.turbo.errors.VisitError
4
+ import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
5
+ import dev.hotwire.navigation.fragments.HotwireWebFragment
6
+
7
+ /**
8
+ * The default destination for every web visit.
9
+ *
10
+ * Deliberately thin — the framework's own fragment already does the work. Its
11
+ * one job is telling [MainActivity] when there is content on screen, which is
12
+ * how the splash comes down. Android has no `NavigatorDelegate.requestDidFinish`
13
+ * the way iOS does; the visit callbacks on the fragment are where that
14
+ * information surfaces.
15
+ */
16
+ @HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
17
+ open class WebFragment : HotwireWebFragment() {
18
+
19
+ override fun onVisitRequestFinished(location: String) {
20
+ super.onVisitRequestFinished(location)
21
+ settled()
22
+ }
23
+
24
+ /**
25
+ * An error is still an answer: the framework has swapped in its error view,
26
+ * so leaving the splash up would hide it behind a spinner that never stops.
27
+ */
28
+ override fun onVisitErrorReceived(location: String, error: VisitError) {
29
+ super.onVisitErrorReceived(location, error)
30
+ settled()
31
+ }
32
+
33
+ private fun settled() {
34
+ (activity as? MainActivity)?.onWebContentSettled()
35
+ }
36
+ }