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
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell.bridge
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.drawable.Drawable
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import android.util.TypedValue
|
|
7
|
+
import android.view.LayoutInflater
|
|
8
|
+
import android.view.MenuItem
|
|
9
|
+
import android.view.View
|
|
10
|
+
import android.view.ViewGroup
|
|
11
|
+
import android.widget.ImageView
|
|
12
|
+
import android.widget.LinearLayout
|
|
13
|
+
import android.widget.TextView
|
|
14
|
+
import androidx.appcompat.widget.Toolbar
|
|
15
|
+
import androidx.core.content.ContextCompat
|
|
16
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
17
|
+
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
18
|
+
import com.rubyeverywhere.shell.IconFont
|
|
19
|
+
import com.rubyeverywhere.shell.R
|
|
20
|
+
import dev.hotwire.core.bridge.BridgeComponent
|
|
21
|
+
import dev.hotwire.core.bridge.BridgeDelegate
|
|
22
|
+
import dev.hotwire.core.bridge.Message
|
|
23
|
+
import dev.hotwire.navigation.destinations.HotwireDestination
|
|
24
|
+
import kotlinx.serialization.SerialName
|
|
25
|
+
import kotlinx.serialization.Serializable
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bridge component backing `everywhere--menu`: the native chrome the page
|
|
29
|
+
* declares with `data-everywhere-nav-*` / `data-everywhere-menu` (the
|
|
30
|
+
* everywhere_nav_button / everywhere_nav_menu / everywhere_menu Rails helpers).
|
|
31
|
+
*
|
|
32
|
+
* Two events, both replied to by echoing back the tapped item's id so the web
|
|
33
|
+
* half can `.click()` the element it mirrors — behavior stays defined once, in
|
|
34
|
+
* the page:
|
|
35
|
+
*
|
|
36
|
+
* * `navItems` — install this screen's toolbar items (plain buttons, and
|
|
37
|
+
* overflow menus for pull-downs). Native re-replies to the same message on
|
|
38
|
+
* every tap, `{action: "tap", id}`, for as long as the page is up.
|
|
39
|
+
* * `menu` — present an action sheet, replying once with
|
|
40
|
+
* `{action: "select", id}` or `{action: "cancel", id: null}`.
|
|
41
|
+
*
|
|
42
|
+
* Both work on `delegate.destination` — the fragment this component instance
|
|
43
|
+
* belongs to — so items land on the right screen and go away with it, exactly
|
|
44
|
+
* like the framework's own per-page bridge components.
|
|
45
|
+
*
|
|
46
|
+
* ### Where this differs from iOS, and why
|
|
47
|
+
*
|
|
48
|
+
* **There is no left side.** iOS splits nav items into
|
|
49
|
+
* `leftBarButtonItems`/`rightBarButtonItems`; an Android toolbar's left slot is
|
|
50
|
+
* the Up button, which belongs to the navigator. `side: "left"` is honoured as
|
|
51
|
+
* ordering — those items sort first — rather than dropped, which keeps the page
|
|
52
|
+
* author's intent ("this one comes before that one") without fighting the
|
|
53
|
+
* platform for a slot it doesn't have.
|
|
54
|
+
*
|
|
55
|
+
* **Pull-downs are action sheets.** iOS attaches a `UIMenu` to the bar button.
|
|
56
|
+
* The Android equivalent that can render Material icons next to each row (a
|
|
57
|
+
* `PopupMenu` hides icons by default) is the bottom sheet the `menu` event
|
|
58
|
+
* already uses, so overflow menus and in-content action sheets share one
|
|
59
|
+
* presentation and one row layout. Upstream's own demo MenuComponent makes the
|
|
60
|
+
* same choice.
|
|
61
|
+
*
|
|
62
|
+
* `source` — the trigger's rect, which iOS needs to anchor an iPad popover — is
|
|
63
|
+
* decoded and ignored: a bottom sheet has nothing to anchor to.
|
|
64
|
+
*/
|
|
65
|
+
class MenuComponent(
|
|
66
|
+
name: String,
|
|
67
|
+
private val delegate: BridgeDelegate<HotwireDestination>
|
|
68
|
+
) : BridgeComponent<HotwireDestination>(name, delegate) {
|
|
69
|
+
|
|
70
|
+
private val toolbar: Toolbar?
|
|
71
|
+
get() = delegate.destination.toolbarForNavigation()
|
|
72
|
+
|
|
73
|
+
private val context: Context?
|
|
74
|
+
get() = delegate.destination.fragment.context
|
|
75
|
+
|
|
76
|
+
/** Toolbar item id → the page item it mirrors, rebuilt on every `navItems`. */
|
|
77
|
+
private val navItemsById = mutableMapOf<Int, NavItem>()
|
|
78
|
+
|
|
79
|
+
override fun onReceive(message: Message) {
|
|
80
|
+
when (message.event) {
|
|
81
|
+
"navItems" -> handleNavItems(message)
|
|
82
|
+
"menu" -> handleMenu(message)
|
|
83
|
+
else -> Log.w(TAG, "Unknown event for message: $message")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// MARK: Nav bar items
|
|
88
|
+
|
|
89
|
+
private fun handleNavItems(message: Message) {
|
|
90
|
+
val toolbar = toolbar ?: return
|
|
91
|
+
val data = message.data<NavItemsData>() ?: return
|
|
92
|
+
|
|
93
|
+
// The page owns this screen's items wholesale: a Turbo visit that
|
|
94
|
+
// renders fewer buttons must not leave the previous page's behind.
|
|
95
|
+
toolbar.menu.removeGroup(GROUP_ID)
|
|
96
|
+
navItemsById.clear()
|
|
97
|
+
|
|
98
|
+
// "left" first so the page's authored order survives, even though every
|
|
99
|
+
// item ends up in the toolbar's right-hand cluster.
|
|
100
|
+
val ordered = data.items.sortedBy { if (it.side == "left") 0 else 1 }
|
|
101
|
+
|
|
102
|
+
ordered.forEachIndexed { index, item ->
|
|
103
|
+
val itemId = ITEM_ID_BASE + index
|
|
104
|
+
navItemsById[itemId] = item
|
|
105
|
+
|
|
106
|
+
val menuItem = toolbar.menu.add(GROUP_ID, itemId, index, item.title)
|
|
107
|
+
// `kind`, not the child count: a pull-down the page rendered empty
|
|
108
|
+
// still belongs in the bar as the ⋯ button it was authored as.
|
|
109
|
+
val drawable = icon(item.image, isOverflow = item.kind == "menu")
|
|
110
|
+
|
|
111
|
+
if (drawable != null) {
|
|
112
|
+
menuItem.icon = drawable
|
|
113
|
+
menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
|
114
|
+
} else {
|
|
115
|
+
menuItem.setShowAsAction(
|
|
116
|
+
MenuItem.SHOW_AS_ACTION_IF_ROOM or MenuItem.SHOW_AS_ACTION_WITH_TEXT
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
menuItem.isEnabled = item.disabled != true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Set unconditionally, not only when we added items: the listener has to
|
|
123
|
+
// be ours for taps to reach the web, and re-setting it is how a second
|
|
124
|
+
// navItems message rebinds after a page swap.
|
|
125
|
+
toolbar.setOnMenuItemClickListener { tapped -> onNavItemTapped(tapped) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun onNavItemTapped(tapped: MenuItem): Boolean {
|
|
129
|
+
val item = navItemsById[tapped.itemId] ?: return false
|
|
130
|
+
val children = item.children
|
|
131
|
+
|
|
132
|
+
if (children.isNullOrEmpty()) {
|
|
133
|
+
// A plain button: echo its id back so the web half clicks the element.
|
|
134
|
+
replyTo("navItems", NavReply(action = "tap", id = item.id))
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A pull-down / overflow menu: each child echoes its own id.
|
|
139
|
+
showSheet(
|
|
140
|
+
title = item.title.takeIf { it.isNotEmpty() },
|
|
141
|
+
message = null,
|
|
142
|
+
items = children,
|
|
143
|
+
onSelect = { id -> replyTo("navItems", NavReply(action = "tap", id = id)) },
|
|
144
|
+
// Dismissing a pull-down is not an answer on iOS either — the web
|
|
145
|
+
// half's callback simply stays live for the next tap.
|
|
146
|
+
onCancel = null
|
|
147
|
+
)
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// MARK: Action sheet
|
|
152
|
+
|
|
153
|
+
private fun handleMenu(message: Message) {
|
|
154
|
+
val data = message.data<MenuData>() ?: return
|
|
155
|
+
|
|
156
|
+
showSheet(
|
|
157
|
+
title = data.title,
|
|
158
|
+
message = data.message,
|
|
159
|
+
items = data.items,
|
|
160
|
+
onSelect = { id -> replyTo("menu", MenuReply(action = "select", id = id)) },
|
|
161
|
+
onCancel = { replyTo("menu", MenuReply(action = "cancel", id = null)) }
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One sheet for both callers. `settled` is what makes the reply one-shot:
|
|
167
|
+
* choosing a row dismisses the dialog, and the dismiss listener must not
|
|
168
|
+
* then send a cancel on top of the selection.
|
|
169
|
+
*/
|
|
170
|
+
private fun showSheet(
|
|
171
|
+
title: String?,
|
|
172
|
+
message: String?,
|
|
173
|
+
items: List<MenuItemData>,
|
|
174
|
+
onSelect: (String) -> Unit,
|
|
175
|
+
onCancel: (() -> Unit)?
|
|
176
|
+
) {
|
|
177
|
+
val context = context ?: return
|
|
178
|
+
val inflater = LayoutInflater.from(context)
|
|
179
|
+
val root = inflater.inflate(R.layout.bridge_menu_sheet, null) as ViewGroup
|
|
180
|
+
val dialog = BottomSheetDialog(context)
|
|
181
|
+
var settled = false
|
|
182
|
+
|
|
183
|
+
val titleView = root.findViewById<TextView>(R.id.bridge_menu_title)
|
|
184
|
+
val messageView = root.findViewById<TextView>(R.id.bridge_menu_message)
|
|
185
|
+
titleView.text = title.orEmpty()
|
|
186
|
+
titleView.visibility = if (title.isNullOrEmpty()) View.GONE else View.VISIBLE
|
|
187
|
+
messageView.text = message.orEmpty()
|
|
188
|
+
messageView.visibility = if (message.isNullOrEmpty()) View.GONE else View.VISIBLE
|
|
189
|
+
root.findViewById<View>(R.id.bridge_menu_header).visibility =
|
|
190
|
+
if (title.isNullOrEmpty() && message.isNullOrEmpty()) View.GONE else View.VISIBLE
|
|
191
|
+
|
|
192
|
+
val list = root.findViewById<LinearLayout>(R.id.bridge_menu_items)
|
|
193
|
+
for (item in items) {
|
|
194
|
+
val row = inflater.inflate(R.layout.bridge_menu_sheet_item, list, false)
|
|
195
|
+
val label = row.findViewById<TextView>(R.id.bridge_menu_item_title)
|
|
196
|
+
val iconView = row.findViewById<ImageView>(R.id.bridge_menu_item_icon)
|
|
197
|
+
|
|
198
|
+
label.text = item.title
|
|
199
|
+
if (item.style == "destructive") {
|
|
200
|
+
label.setTextColor(themeColor(row.context, ERROR_ATTR, DESTRUCTIVE_FALLBACK))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
val drawable = icon(item.image, isOverflow = false)
|
|
204
|
+
if (drawable != null) {
|
|
205
|
+
iconView.setImageDrawable(drawable)
|
|
206
|
+
iconView.visibility = View.VISIBLE
|
|
207
|
+
} else {
|
|
208
|
+
// Kept in the layout (INVISIBLE, not GONE) so rows with and
|
|
209
|
+
// without icons still line their titles up.
|
|
210
|
+
iconView.visibility = if (items.any { it.image != null }) View.INVISIBLE else View.GONE
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (item.disabled == true) {
|
|
214
|
+
row.isEnabled = false
|
|
215
|
+
row.alpha = DISABLED_ALPHA
|
|
216
|
+
} else {
|
|
217
|
+
row.setOnClickListener {
|
|
218
|
+
if (settled) return@setOnClickListener
|
|
219
|
+
settled = true
|
|
220
|
+
dialog.dismiss()
|
|
221
|
+
onSelect(item.id)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
list.addView(row)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
root.findViewById<View>(R.id.bridge_menu_cancel).setOnClickListener { dialog.cancel() }
|
|
228
|
+
|
|
229
|
+
// Covers the Cancel row, the back button and a swipe-away alike — all of
|
|
230
|
+
// them are the same "no" iOS reports from its cancel action.
|
|
231
|
+
dialog.setOnDismissListener {
|
|
232
|
+
if (settled) return@setOnDismissListener
|
|
233
|
+
settled = true
|
|
234
|
+
onCancel?.invoke()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
dialog.setContentView(root)
|
|
238
|
+
dialog.show()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// MARK: Icons
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Page-declared Material icon names, drawn by the shell's bundled icon font.
|
|
245
|
+
* `IconFont` is the only icon path in the shell — tabs resolve names the
|
|
246
|
+
* same way, and it already understands the `favorite.fill` spelling that
|
|
247
|
+
* mirrors `icons.ios: heart.fill` — so a name that works on a tab works
|
|
248
|
+
* here, and the FILL rule lives in exactly one place.
|
|
249
|
+
*
|
|
250
|
+
* One translation happens first: bridge.js defaults an overflow menu's icon
|
|
251
|
+
* to the literal SF Symbol `ellipsis.circle` when the page names no Android
|
|
252
|
+
* icon. Passed through, that resolves to nothing and IconFont falls back to
|
|
253
|
+
* a plain circle, so it is mapped to the Material name meaning the same thing.
|
|
254
|
+
*/
|
|
255
|
+
private fun icon(name: String?, isOverflow: Boolean): Drawable? {
|
|
256
|
+
val context = context ?: return null
|
|
257
|
+
val requested = when {
|
|
258
|
+
!name.isNullOrEmpty() && name != IOS_OVERFLOW_ICON -> name
|
|
259
|
+
isOverflow -> ANDROID_OVERFLOW_ICON
|
|
260
|
+
else -> return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
val size = TypedValue.applyDimension(
|
|
264
|
+
TypedValue.COMPLEX_UNIT_DIP, ICON_DP, context.resources.displayMetrics
|
|
265
|
+
).toInt()
|
|
266
|
+
|
|
267
|
+
return try {
|
|
268
|
+
// IconFont draws opaque white and leaves colour to the host, so
|
|
269
|
+
// tinting here is required, not decorative: an untinted icon is an
|
|
270
|
+
// invisible one in light mode.
|
|
271
|
+
DrawableCompat.wrap(IconFont.drawable(context, requested, size)).mutate().also {
|
|
272
|
+
DrawableCompat.setTint(it, themeColor(context, CONTROL_ATTR, ICON_FALLBACK))
|
|
273
|
+
}
|
|
274
|
+
} catch (e: Exception) {
|
|
275
|
+
Log.w(TAG, "could not render icon '$requested'", e)
|
|
276
|
+
null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private fun themeColor(context: Context, attr: Int, fallback: Int): Int {
|
|
281
|
+
val value = TypedValue()
|
|
282
|
+
if (!context.theme.resolveAttribute(attr, value, true)) return fallback
|
|
283
|
+
return if (value.resourceId != 0) ContextCompat.getColor(context, value.resourceId) else value.data
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// MARK: Message data
|
|
287
|
+
|
|
288
|
+
@Serializable
|
|
289
|
+
data class NavItemsData(
|
|
290
|
+
@SerialName("items") val items: List<NavItem>
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@Serializable
|
|
294
|
+
data class NavItem(
|
|
295
|
+
@SerialName("id") val id: String,
|
|
296
|
+
@SerialName("kind") val kind: String? = null,
|
|
297
|
+
@SerialName("title") val title: String = "",
|
|
298
|
+
@SerialName("image") val image: String? = null,
|
|
299
|
+
@SerialName("side") val side: String? = null,
|
|
300
|
+
@SerialName("style") val style: String? = null,
|
|
301
|
+
@SerialName("disabled") val disabled: Boolean? = null,
|
|
302
|
+
@SerialName("children") val children: List<MenuItemData>? = null
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@Serializable
|
|
306
|
+
data class MenuData(
|
|
307
|
+
@SerialName("title") val title: String? = null,
|
|
308
|
+
@SerialName("message") val message: String? = null,
|
|
309
|
+
@SerialName("items") val items: List<MenuItemData> = emptyList(),
|
|
310
|
+
@SerialName("source") val source: Source? = null
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@Serializable
|
|
314
|
+
data class MenuItemData(
|
|
315
|
+
@SerialName("id") val id: String,
|
|
316
|
+
@SerialName("title") val title: String = "",
|
|
317
|
+
@SerialName("image") val image: String? = null,
|
|
318
|
+
@SerialName("style") val style: String? = null,
|
|
319
|
+
@SerialName("disabled") val disabled: Boolean? = null
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
/** The trigger's rect in page coordinates. Decoded for parity; unused here. */
|
|
323
|
+
@Serializable
|
|
324
|
+
data class Source(
|
|
325
|
+
@SerialName("x") val x: Double = 0.0,
|
|
326
|
+
@SerialName("y") val y: Double = 0.0,
|
|
327
|
+
@SerialName("width") val width: Double = 0.0,
|
|
328
|
+
@SerialName("height") val height: Double = 0.0
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@Serializable
|
|
332
|
+
data class NavReply(
|
|
333
|
+
@SerialName("action") val action: String,
|
|
334
|
+
@SerialName("id") val id: String
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
@Serializable
|
|
338
|
+
data class MenuReply(
|
|
339
|
+
@SerialName("action") val action: String,
|
|
340
|
+
@SerialName("id") val id: String? = null
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
private companion object {
|
|
344
|
+
const val TAG = "EverywhereMenu"
|
|
345
|
+
|
|
346
|
+
/** Our own group, so removeGroup() never touches the fragment's own menu. */
|
|
347
|
+
const val GROUP_ID = 0x45564D55 // "EVMU"
|
|
348
|
+
const val ITEM_ID_BASE = 0x45560000
|
|
349
|
+
|
|
350
|
+
const val ICON_DP = 24f
|
|
351
|
+
const val DISABLED_ALPHA = 0.38f
|
|
352
|
+
const val IOS_OVERFLOW_ICON = "ellipsis.circle"
|
|
353
|
+
const val ANDROID_OVERFLOW_ICON = "more_vert"
|
|
354
|
+
|
|
355
|
+
val CONTROL_ATTR = androidx.appcompat.R.attr.colorControlNormal
|
|
356
|
+
val ERROR_ATTR = com.google.android.material.R.attr.colorError
|
|
357
|
+
|
|
358
|
+
const val ICON_FALLBACK = 0xFF444444.toInt()
|
|
359
|
+
const val DESTRUCTIVE_FALLBACK = 0xFFB3261E.toInt()
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell.bridge
|
|
2
|
+
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import android.util.Log
|
|
8
|
+
import androidx.core.app.NotificationCompat
|
|
9
|
+
import androidx.core.app.NotificationManagerCompat
|
|
10
|
+
import dev.hotwire.core.bridge.BridgeComponent
|
|
11
|
+
import dev.hotwire.core.bridge.BridgeDelegate
|
|
12
|
+
import dev.hotwire.core.bridge.Message
|
|
13
|
+
import dev.hotwire.navigation.destinations.HotwireDestination
|
|
14
|
+
import kotlinx.serialization.SerialName
|
|
15
|
+
import kotlinx.serialization.Serializable
|
|
16
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bridge component backing the `everywhere--notification` web component.
|
|
20
|
+
* Handles `notify` events by requesting permission (if needed) and delivering an
|
|
21
|
+
* immediate local notification, then replies `{delivered: Boolean}` — the same
|
|
22
|
+
* event, the same payload keys and the same reply shape as iOS's
|
|
23
|
+
* NotificationComponent.swift.
|
|
24
|
+
*
|
|
25
|
+
* Two Android facts shape the implementation and neither changes the wire:
|
|
26
|
+
*
|
|
27
|
+
* * A notification needs a **channel** on API 26+, so the shell owns one
|
|
28
|
+
* ("everywhere_default"), created lazily and idempotently.
|
|
29
|
+
* * `POST_NOTIFICATIONS` became a **runtime** permission on API 33. Below that
|
|
30
|
+
* there is nothing to ask for and `areNotificationsEnabled()` is the whole
|
|
31
|
+
* answer. iOS asks inside notify() rather than making the page ask first, so
|
|
32
|
+
* this does too.
|
|
33
|
+
*/
|
|
34
|
+
class NotificationComponent(
|
|
35
|
+
name: String,
|
|
36
|
+
private val delegate: BridgeDelegate<HotwireDestination>
|
|
37
|
+
) : BridgeComponent<HotwireDestination>(name, delegate) {
|
|
38
|
+
|
|
39
|
+
private val context: Context?
|
|
40
|
+
get() = delegate.destination.fragment.context
|
|
41
|
+
|
|
42
|
+
override fun onReceive(message: Message) {
|
|
43
|
+
when (message.event) {
|
|
44
|
+
"notify" -> handleNotify(message)
|
|
45
|
+
else -> Log.w(TAG, "Unknown event for message: $message")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun handleNotify(message: Message) {
|
|
50
|
+
// A payload that won't decode is the only refusal iOS makes here, and
|
|
51
|
+
// it makes the same one — an empty title still posts.
|
|
52
|
+
val data = message.data<MessageData>() ?: return reply(message, delivered = false)
|
|
53
|
+
val context = context ?: return reply(message, delivered = false)
|
|
54
|
+
|
|
55
|
+
// Already allowed (or on an API where there is nothing to allow):
|
|
56
|
+
// deliver without a detour through the permission machinery.
|
|
57
|
+
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
|
|
58
|
+
return reply(message, delivered = deliver(context, data))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
62
|
+
// Notifications are off in system settings. There is no prompt for
|
|
63
|
+
// that — only a settings trip the page must offer itself
|
|
64
|
+
// (Everywhere.permissions.openSettings()).
|
|
65
|
+
return reply(message, delivered = false)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
RuntimePermissions.request(delegate.destination.fragment, POST_NOTIFICATIONS) { granted ->
|
|
69
|
+
reply(message, delivered = granted && deliver(context, data))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun deliver(context: Context, data: MessageData): Boolean = try {
|
|
74
|
+
ensureChannel(context)
|
|
75
|
+
|
|
76
|
+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
77
|
+
.setSmallIcon(smallIcon(context))
|
|
78
|
+
.setContentTitle(data.title)
|
|
79
|
+
.apply { data.body?.takeIf { it.isNotEmpty() }?.let { setContentText(it) } }
|
|
80
|
+
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
|
81
|
+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
82
|
+
.setAutoCancel(true)
|
|
83
|
+
.build()
|
|
84
|
+
|
|
85
|
+
// notify() throws SecurityException without the permission — which the
|
|
86
|
+
// check above should have caught, but a permission revoked between the
|
|
87
|
+
// check and here must fail as `delivered: false`, not as a crash.
|
|
88
|
+
NotificationManagerCompat.from(context).notify(nextId.incrementAndGet(), notification)
|
|
89
|
+
true
|
|
90
|
+
} catch (e: SecurityException) {
|
|
91
|
+
Log.w(TAG, "notify: not permitted", e)
|
|
92
|
+
false
|
|
93
|
+
} catch (e: Exception) {
|
|
94
|
+
Log.w(TAG, "notify: failed", e)
|
|
95
|
+
false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun ensureChannel(context: Context) {
|
|
99
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
100
|
+
|
|
101
|
+
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
|
102
|
+
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
|
103
|
+
|
|
104
|
+
manager.createNotificationChannel(
|
|
105
|
+
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A notification's small icon has to be a flat white silhouette — Android
|
|
111
|
+
* masks it — so the launcher icon reads as a blob. An app that ships a
|
|
112
|
+
* proper one as `res/drawable/ic_notification` gets it picked up here
|
|
113
|
+
* without any wiring; everyone else gets the launcher icon, which is at
|
|
114
|
+
* least recognisable.
|
|
115
|
+
*/
|
|
116
|
+
private fun smallIcon(context: Context): Int {
|
|
117
|
+
val named = context.resources.getIdentifier("ic_notification", "drawable", context.packageName)
|
|
118
|
+
return if (named != 0) named else context.applicationInfo.icon
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Replies on the exact message that asked. `replyTo(event)` targets only the
|
|
123
|
+
* last-received message for an event, which is wrong the moment two pages
|
|
124
|
+
* (or two calls) have notify() in flight — the same reasoning as iOS's
|
|
125
|
+
* `message.replacing(data:)` replies.
|
|
126
|
+
*/
|
|
127
|
+
private fun reply(message: Message, delivered: Boolean) {
|
|
128
|
+
replyWith(message.replacing(event = message.event, data = NotifyReply(delivered = delivered)))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Serializable
|
|
132
|
+
data class MessageData(
|
|
133
|
+
@SerialName("title") val title: String,
|
|
134
|
+
@SerialName("body") val body: String? = null
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@Serializable
|
|
138
|
+
data class NotifyReply(
|
|
139
|
+
@SerialName("delivered") val delivered: Boolean
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
private companion object {
|
|
143
|
+
const val TAG = "EverywhereNotification"
|
|
144
|
+
const val CHANNEL_ID = "everywhere_default"
|
|
145
|
+
const val CHANNEL_NAME = "Notifications"
|
|
146
|
+
const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
|
|
147
|
+
|
|
148
|
+
/** Distinct ids so a second notify() doesn't silently replace the first. */
|
|
149
|
+
val nextId = AtomicInteger(1_000)
|
|
150
|
+
}
|
|
151
|
+
}
|