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,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
+ }