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,535 @@
1
+ package com.rubyeverywhere.shell.bridge
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+ import android.security.keystore.KeyGenParameterSpec
8
+ import android.security.keystore.KeyPermanentlyInvalidatedException
9
+ import android.security.keystore.KeyProperties
10
+ import android.util.Base64
11
+ import android.util.Log
12
+ import androidx.biometric.BiometricManager
13
+ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
14
+ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
15
+ import androidx.biometric.BiometricPrompt
16
+ import androidx.core.content.ContextCompat
17
+ import androidx.fragment.app.Fragment
18
+ import androidx.security.crypto.EncryptedSharedPreferences
19
+ import androidx.security.crypto.MasterKey
20
+ import com.rubyeverywhere.shell.EverywhereConfig
21
+ import dev.hotwire.core.bridge.BridgeComponent
22
+ import dev.hotwire.core.bridge.BridgeDelegate
23
+ import dev.hotwire.core.bridge.Message
24
+ import dev.hotwire.navigation.destinations.HotwireDestination
25
+ import kotlinx.serialization.SerialName
26
+ import kotlinx.serialization.Serializable
27
+ import java.security.KeyStore
28
+ import javax.crypto.Cipher
29
+ import javax.crypto.KeyGenerator
30
+ import javax.crypto.SecretKey
31
+ import javax.crypto.spec.GCMParameterSpec
32
+
33
+ /**
34
+ * Bridge component backing `everywhere--biometrics`. Handles `query`
35
+ * (availability + biometry type), `authenticate` (the biometric prompt,
36
+ * optionally falling back to the device credential), and the `credential*`
37
+ * events (a biometric-protected secret powering "sign in with Face ID"),
38
+ * replying `{available, biometry, status}`, `{authenticated, error?}` and
39
+ * `{stored|token|enrolled|cleared, error?}` — the same events, keys and status
40
+ * vocabulary as iOS's BiometricsComponent.swift.
41
+ *
42
+ * Biometrics must be declared in everywhere.yml, and undeclared use
43
+ * short-circuits before any system API. On iOS that gate stops a crash; here it
44
+ * keeps one everywhere.yml meaning one thing on both platforms.
45
+ *
46
+ * This proves presence to the PAGE only (gate a screen, confirm an action); it
47
+ * is not authentication the server can trust.
48
+ *
49
+ * ### Vocabulary
50
+ *
51
+ * `biometry` reports iOS's names — faceID / touchID / opticID / none — for
52
+ * Android's face / fingerprint / iris hardware. Deliberately: the value exists
53
+ * so a page can label its own button, and a page that switches on it would
54
+ * otherwise need two vocabularies to say the same three things. The label
55
+ * "Face ID" on an Android face-unlock device is the wrong brand; a value the
56
+ * page has never heard of is a blank button.
57
+ *
58
+ * ### The credential
59
+ *
60
+ * iOS stores the token in the keychain under `SecAccessControl(.biometryCurrentSet)`:
61
+ * reading it *is* the prompt, and iOS invalidates the item whenever biometric
62
+ * enrollment changes. The Android equivalent is an AES/GCM key in the
63
+ * AndroidKeyStore with `setUserAuthenticationRequired(true)` and
64
+ * `setInvalidatedByBiometricEnrollment(true)`, used through a `CryptoObject` —
65
+ * so the hardware, not this code, enforces both properties, and a new face or
66
+ * finger raises `KeyPermanentlyInvalidatedException`, which becomes the same
67
+ * `notEnrolled` iOS reports.
68
+ *
69
+ * The sealed bytes then go into EncryptedSharedPreferences. That is belt and
70
+ * braces rather than the actual protection — the value is already AES-GCM
71
+ * ciphertext under a hardware key — which is what makes it safe to fall back to
72
+ * plain SharedPreferences if `androidx.security-crypto` (still alpha, and known
73
+ * to fail to initialise on a handful of devices) throws at startup. The failure
74
+ * mode of that fallback is a re-enrollment, not an exposed token.
75
+ */
76
+ class BiometricsComponent(
77
+ name: String,
78
+ private val delegate: BridgeDelegate<HotwireDestination>
79
+ ) : BridgeComponent<HotwireDestination>(name, delegate) {
80
+
81
+ private val fragment: Fragment
82
+ get() = delegate.destination.fragment
83
+
84
+ override fun onReceive(message: Message) {
85
+ val declared = EverywhereConfig.shared.declaresPermission("biometrics")
86
+
87
+ when (message.event) {
88
+ "query" ->
89
+ if (declared) query(message)
90
+ else reply(message, QueryReply(available = false, biometry = "none", status = "undeclared"))
91
+
92
+ "authenticate" ->
93
+ if (declared) authenticate(message)
94
+ else reply(message, AuthReply(authenticated = false, error = "undeclared"))
95
+
96
+ "credentialStore", "credentialGet", "credentialStatus", "credentialClear" ->
97
+ if (declared) credential(message)
98
+ else reply(message, CredentialReply(error = "undeclared"))
99
+
100
+ else -> Log.w(TAG, "Unknown event for message: $message")
101
+ }
102
+ }
103
+
104
+ // MARK: Availability
105
+
106
+ private fun query(message: Message) {
107
+ val context = fragment.context ?: return reply(
108
+ message, QueryReply(available = false, biometry = "none", status = "notAvailable")
109
+ )
110
+
111
+ val code = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG)
112
+ reply(
113
+ message,
114
+ QueryReply(
115
+ available = code == BiometricManager.BIOMETRIC_SUCCESS,
116
+ biometry = biometryName(context),
117
+ status = if (code == BiometricManager.BIOMETRIC_SUCCESS) "available"
118
+ else describeAvailability(code)
119
+ )
120
+ )
121
+ }
122
+
123
+ /**
124
+ * The hardware this device actually has. A device with more than one kind
125
+ * gets the most prominent — iOS reports a single type too, and the page is
126
+ * choosing one label.
127
+ */
128
+ private fun biometryName(context: Context): String {
129
+ val packages = context.packageManager
130
+ return when {
131
+ packages.hasSystemFeature(PackageManager.FEATURE_FACE) -> "faceID"
132
+ packages.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) -> "touchID"
133
+ packages.hasSystemFeature(PackageManager.FEATURE_IRIS) -> "opticID"
134
+ else -> "none"
135
+ }
136
+ }
137
+
138
+ /**
139
+ * `canAuthenticate` codes → the JS status vocabulary. Note the gap:
140
+ * Android surfaces a temporary lockout only from an authentication attempt
141
+ * (`ERROR_LOCKOUT`), never from this call, where it is indistinguishable
142
+ * from genuinely absent hardware. Calling that `lockout` here would tell the
143
+ * declarative gate to keep prompting a device with no sensor.
144
+ */
145
+ private fun describeAvailability(code: Int): String = when (code) {
146
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "notEnrolled"
147
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
148
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
149
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED,
150
+ BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> "notAvailable"
151
+ else -> "notAvailable"
152
+ }
153
+
154
+ /**
155
+ * `BiometricPrompt` error codes → the same vocabulary. The negative button
156
+ * is ours and it says Cancel, so it is a cancel, not iOS's `fallback` (which
157
+ * is its "Enter Passcode" button — a thing `allowPasscode` handles here by
158
+ * putting the credential *inside* the same prompt).
159
+ */
160
+ private fun describeError(errorCode: Int): String = when (errorCode) {
161
+ BiometricPrompt.ERROR_USER_CANCELED,
162
+ BiometricPrompt.ERROR_CANCELED,
163
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> "canceled"
164
+ BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> "lockout"
165
+ BiometricPrompt.ERROR_NO_BIOMETRICS -> "notEnrolled"
166
+ BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE -> "notAvailable"
167
+ BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> "passcodeNotSet"
168
+ else -> "failed"
169
+ }
170
+
171
+ // MARK: Authenticate
172
+
173
+ private fun authenticate(message: Message) {
174
+ val data = message.data<Payload>()
175
+ val reason = data?.reason?.trim().takeUnless { it.isNullOrEmpty() } ?: "Confirm it's you."
176
+
177
+ prompt(reason, allowPasscode = data?.allowPasscode == true, crypto = null,
178
+ onError = { reply(message, AuthReply(authenticated = false, error = it)) },
179
+ onSuccess = { reply(message, AuthReply(authenticated = true)) })
180
+ }
181
+
182
+ // MARK: Credential
183
+
184
+ private fun credential(message: Message) {
185
+ val context = fragment.context ?: return reply(message, CredentialReply(error = "notAvailable"))
186
+ val data = message.data<Payload>()
187
+
188
+ when (message.event) {
189
+ "credentialStore" -> storeCredential(context, message, data)
190
+ "credentialGet" -> readCredential(context, message, data)
191
+ "credentialStatus" -> reply(
192
+ message,
193
+ CredentialReply(enrolled = readBlob(context) != null && keyExists())
194
+ )
195
+ else -> { // credentialClear
196
+ clearCredential(context)
197
+ reply(message, CredentialReply(cleared = true))
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Storing runs the prompt too: writing doesn't need the key's ACL satisfied
204
+ * on every platform, but enabling a sign-in credential is a security change
205
+ * — confirm it's the owner. Same call, same reason, as iOS.
206
+ *
207
+ * The key is recreated from scratch on every store, mirroring iOS's
208
+ * delete-then-add: a store is a fresh enrollment, and it should be bound to
209
+ * the biometrics enrolled right now.
210
+ */
211
+ private fun storeCredential(context: Context, message: Message, data: Payload?) {
212
+ val token = data?.token
213
+ if (token.isNullOrEmpty()) {
214
+ return reply(message, CredentialReply(stored = false, error = "failed"))
215
+ }
216
+
217
+ deleteKey()
218
+ val cipher = try {
219
+ Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, createKey()) }
220
+ } catch (e: Exception) {
221
+ Log.w(TAG, "credentialStore: could not prepare the key", e)
222
+ return reply(message, CredentialReply(stored = false, error = "notAvailable"))
223
+ }
224
+
225
+ prompt(
226
+ reason = data.reason?.trim().takeUnless { it.isNullOrEmpty() } ?: "Enable biometric sign-in",
227
+ allowPasscode = false,
228
+ crypto = BiometricPrompt.CryptoObject(cipher),
229
+ onError = { reply(message, CredentialReply(stored = false, error = it)) },
230
+ onSuccess = { result ->
231
+ val sealed = result?.cryptoObject?.cipher
232
+ val payload = try {
233
+ requireNotNull(sealed)
234
+ val bytes = sealed.doFinal(token.toByteArray(Charsets.UTF_8))
235
+ writeBlob(context, encode(sealed.iv, bytes))
236
+ CredentialReply(stored = true)
237
+ } catch (e: Exception) {
238
+ Log.w(TAG, "credentialStore: failed", e)
239
+ CredentialReply(stored = false, error = "failed")
240
+ }
241
+ reply(message, payload)
242
+ }
243
+ )
244
+ }
245
+
246
+ private fun readCredential(context: Context, message: Message, data: Payload?) {
247
+ val blob = readBlob(context)?.let(::decode)
248
+ val key = if (blob == null) null else loadKey()
249
+ if (blob == null || key == null) {
250
+ // Nothing stored, or the key is gone from under it. iOS answers the
251
+ // missing-item case `notEnrolled`, which the page reads as "offer
252
+ // password sign-in" — the right thing here too.
253
+ return reply(message, CredentialReply(error = "notEnrolled"))
254
+ }
255
+
256
+ val cipher = try {
257
+ Cipher.getInstance(TRANSFORMATION)
258
+ .apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, blob.first)) }
259
+ } catch (e: KeyPermanentlyInvalidatedException) {
260
+ // Biometric enrollment changed — the exact case iOS's
261
+ // .biometryCurrentSet flag covers. The stored bytes can never be
262
+ // read again, so clear them rather than leaving a permanent error.
263
+ Log.i(TAG, "credentialGet: invalidated by a biometric enrollment change")
264
+ clearCredential(context)
265
+ return reply(message, CredentialReply(error = "notEnrolled"))
266
+ } catch (e: Exception) {
267
+ Log.w(TAG, "credentialGet: could not prepare the cipher", e)
268
+ return reply(message, CredentialReply(error = "failed"))
269
+ }
270
+
271
+ prompt(
272
+ reason = data?.reason?.trim().takeUnless { it.isNullOrEmpty() } ?: "Sign in",
273
+ allowPasscode = false,
274
+ crypto = BiometricPrompt.CryptoObject(cipher),
275
+ onError = { reply(message, CredentialReply(error = it)) },
276
+ onSuccess = { result ->
277
+ val opened = result?.cryptoObject?.cipher
278
+ val payload = try {
279
+ requireNotNull(opened)
280
+ CredentialReply(token = String(opened.doFinal(blob.second), Charsets.UTF_8))
281
+ } catch (e: Exception) {
282
+ Log.w(TAG, "credentialGet: failed", e)
283
+ CredentialReply(error = "failed")
284
+ }
285
+ reply(message, payload)
286
+ }
287
+ )
288
+ }
289
+
290
+ private fun clearCredential(context: Context) {
291
+ deleteKey()
292
+ credentialPrefs(context)?.edit()?.remove(BLOB_KEY)?.apply()
293
+ }
294
+
295
+ // MARK: Prompt
296
+
297
+ /**
298
+ * One prompt for all four callers. `BiometricPrompt` must be built on the
299
+ * main thread and from a Fragment or FragmentActivity — which is exactly
300
+ * what a bridge component has, and why (unlike iOS, whose keychain calls
301
+ * block) nothing here needs a background queue.
302
+ *
303
+ * `onAuthenticationFailed` — a finger the sensor didn't recognise — is
304
+ * deliberately not an outcome: the prompt stays up and the user tries again,
305
+ * and answering the web there would settle a promise the user is still
306
+ * working on.
307
+ */
308
+ private fun prompt(
309
+ reason: String,
310
+ allowPasscode: Boolean,
311
+ crypto: BiometricPrompt.CryptoObject?,
312
+ onError: (String) -> Unit,
313
+ onSuccess: (BiometricPrompt.AuthenticationResult?) -> Unit
314
+ ) {
315
+ val context = fragment.context ?: return onError("notAvailable")
316
+
317
+ val builder = BiometricPrompt.PromptInfo.Builder()
318
+ // iOS's localizedReason is the prompt's own sentence, and the title
319
+ // is the most prominent text Android gives us for it.
320
+ .setTitle(reason)
321
+ .setConfirmationRequired(false)
322
+
323
+ // BIOMETRIC_STRONG or DEVICE_CREDENTIAL is unsupported below API 30, and
324
+ // the pre-30 API for it (setDeviceCredentialAllowed) is long deprecated.
325
+ // So on 28/29 allowPasscode degrades to biometrics-only rather than
326
+ // risking a prompt the framework refuses to show at all.
327
+ if (allowPasscode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
328
+ builder.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
329
+ } else {
330
+ builder.setAllowedAuthenticators(BIOMETRIC_STRONG)
331
+ // Mandatory whenever a device credential isn't allowed, and it must
332
+ // not be set when one is.
333
+ builder.setNegativeButtonText("Cancel")
334
+ }
335
+
336
+ val prompt = BiometricPrompt(
337
+ fragment,
338
+ ContextCompat.getMainExecutor(context),
339
+ object : BiometricPrompt.AuthenticationCallback() {
340
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
341
+ onError(describeError(errorCode))
342
+ }
343
+
344
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
345
+ onSuccess(result)
346
+ }
347
+ }
348
+ )
349
+
350
+ try {
351
+ if (crypto != null) prompt.authenticate(builder.build(), crypto)
352
+ else prompt.authenticate(builder.build())
353
+ } catch (e: Exception) {
354
+ Log.w(TAG, "biometric prompt could not be shown", e)
355
+ onError("notAvailable")
356
+ }
357
+ }
358
+
359
+ // MARK: Keystore
360
+
361
+ private fun keyAlias(): String =
362
+ (fragment.context?.packageName ?: "com.rubyeverywhere.app") + KEY_SUFFIX
363
+
364
+ private fun keyStore(): KeyStore? = try {
365
+ KeyStore.getInstance(KEYSTORE).apply { load(null) }
366
+ } catch (e: Exception) {
367
+ Log.w(TAG, "AndroidKeyStore unavailable", e)
368
+ null
369
+ }
370
+
371
+ private fun keyExists(): Boolean = try {
372
+ keyStore()?.containsAlias(keyAlias()) == true
373
+ } catch (e: Exception) {
374
+ false
375
+ }
376
+
377
+ private fun loadKey(): SecretKey? = try {
378
+ keyStore()?.getKey(keyAlias(), null) as? SecretKey
379
+ } catch (e: Exception) {
380
+ Log.w(TAG, "could not load the credential key", e)
381
+ null
382
+ }
383
+
384
+ private fun deleteKey() {
385
+ try {
386
+ keyStore()?.deleteEntry(keyAlias())
387
+ } catch (e: Exception) {
388
+ Log.w(TAG, "could not delete the credential key", e)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * The two flags are the whole security model, and they are the direct
394
+ * translation of iOS's SecAccessControl: authentication required for every
395
+ * single use, and permanent invalidation when biometric enrollment changes.
396
+ */
397
+ private fun createKey(): SecretKey {
398
+ val builder = KeyGenParameterSpec.Builder(
399
+ keyAlias(),
400
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
401
+ )
402
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
403
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
404
+ .setKeySize(256)
405
+ .setUserAuthenticationRequired(true)
406
+ .setInvalidatedByBiometricEnrollment(true)
407
+
408
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
409
+ // 0 seconds = every use needs a fresh authentication.
410
+ builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
411
+ } else {
412
+ @Suppress("DEPRECATION")
413
+ builder.setUserAuthenticationValidityDurationSeconds(-1) // the pre-30 spelling of the same thing
414
+ }
415
+
416
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
417
+ generator.init(builder.build())
418
+ return generator.generateKey()
419
+ }
420
+
421
+ // MARK: Sealed-blob storage
422
+
423
+ /**
424
+ * EncryptedSharedPreferences when it initialises, plain SharedPreferences
425
+ * when it doesn't. See the class comment: what is stored here is already
426
+ * AES-GCM ciphertext under a hardware-backed, biometric-gated key, so the
427
+ * encrypted file is defence in depth and the fallback costs a re-enrollment
428
+ * at worst — which is a far better outcome than a shell that crashes at
429
+ * launch on the devices where the alpha library misbehaves.
430
+ */
431
+ private fun credentialPrefs(context: Context): SharedPreferences? {
432
+ val app = context.applicationContext
433
+ cachedPrefs?.let { return it }
434
+
435
+ val prefs = try {
436
+ EncryptedSharedPreferences.create(
437
+ app,
438
+ ENCRYPTED_PREFS,
439
+ MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
440
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
441
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
442
+ )
443
+ } catch (e: Exception) {
444
+ Log.w(TAG, "EncryptedSharedPreferences unavailable; falling back to plain preferences", e)
445
+ app.getSharedPreferences(PLAIN_PREFS, Context.MODE_PRIVATE)
446
+ }
447
+
448
+ cachedPrefs = prefs
449
+ return prefs
450
+ }
451
+
452
+ private fun readBlob(context: Context): String? = credentialPrefs(context)?.getString(BLOB_KEY, null)
453
+
454
+ private fun writeBlob(context: Context, blob: String) {
455
+ credentialPrefs(context)?.edit()?.putString(BLOB_KEY, blob)?.apply()
456
+ }
457
+
458
+ private fun encode(iv: ByteArray, ciphertext: ByteArray): String =
459
+ Base64.encodeToString(iv, Base64.NO_WRAP) + "." + Base64.encodeToString(ciphertext, Base64.NO_WRAP)
460
+
461
+ private fun decode(blob: String): Pair<ByteArray, ByteArray>? = try {
462
+ val (iv, ciphertext) = blob.split(".", limit = 2)
463
+ Base64.decode(iv, Base64.NO_WRAP) to Base64.decode(ciphertext, Base64.NO_WRAP)
464
+ } catch (e: Exception) {
465
+ Log.w(TAG, "stored credential is unreadable", e)
466
+ null
467
+ }
468
+
469
+ /**
470
+ * Replies on the exact message that asked (same reasoning as
471
+ * PermissionsComponent: concurrent messages for one event make
472
+ * `replyTo(event)` target only the last-received message).
473
+ *
474
+ * Three concrete overloads rather than one `inline fun <reified T>`: wrapping
475
+ * `Message.replacing` — itself inline and reified — inside another inline
476
+ * function crashes the Kotlin 2.3 JVM backend while inlining the default
477
+ * argument. Passing `event` explicitly, and not nesting the inlines, keeps
478
+ * every call on the path the compiler can actually generate.
479
+ */
480
+ private fun reply(message: Message, payload: QueryReply) {
481
+ replyWith(message.replacing(event = message.event, data = payload))
482
+ }
483
+
484
+ private fun reply(message: Message, payload: AuthReply) {
485
+ replyWith(message.replacing(event = message.event, data = payload))
486
+ }
487
+
488
+ private fun reply(message: Message, payload: CredentialReply) {
489
+ replyWith(message.replacing(event = message.event, data = payload))
490
+ }
491
+
492
+ @Serializable
493
+ data class Payload(
494
+ @SerialName("reason") val reason: String? = null,
495
+ @SerialName("allowPasscode") val allowPasscode: Boolean? = null,
496
+ @SerialName("token") val token: String? = null
497
+ )
498
+
499
+ @Serializable
500
+ data class QueryReply(
501
+ @SerialName("available") val available: Boolean,
502
+ @SerialName("biometry") val biometry: String,
503
+ @SerialName("status") val status: String
504
+ )
505
+
506
+ @Serializable
507
+ data class AuthReply(
508
+ @SerialName("authenticated") val authenticated: Boolean,
509
+ @SerialName("error") val error: String? = null
510
+ )
511
+
512
+ @Serializable
513
+ data class CredentialReply(
514
+ @SerialName("stored") val stored: Boolean? = null,
515
+ @SerialName("token") val token: String? = null,
516
+ @SerialName("enrolled") val enrolled: Boolean? = null,
517
+ @SerialName("cleared") val cleared: Boolean? = null,
518
+ @SerialName("error") val error: String? = null
519
+ )
520
+
521
+ private companion object {
522
+ const val TAG = "EverywhereBiometrics"
523
+ const val KEYSTORE = "AndroidKeyStore"
524
+ const val KEY_SUFFIX = ".biometric-login"
525
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
526
+ const val GCM_TAG_BITS = 128
527
+ const val ENCRYPTED_PREFS = "everywhere.credential"
528
+ const val PLAIN_PREFS = "everywhere.credential.plain"
529
+ const val BLOB_KEY = "login"
530
+
531
+ /** Building EncryptedSharedPreferences is expensive; the file is one per app. */
532
+ @Volatile
533
+ var cachedPrefs: SharedPreferences? = null
534
+ }
535
+ }
@@ -0,0 +1,141 @@
1
+ package com.rubyeverywhere.shell.bridge
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+ import android.os.VibrationEffect
8
+ import android.os.Vibrator
9
+ import android.os.VibratorManager
10
+ import android.util.Log
11
+ import android.view.HapticFeedbackConstants
12
+ import android.view.View
13
+ import androidx.core.content.ContextCompat
14
+ import dev.hotwire.core.bridge.BridgeComponent
15
+ import dev.hotwire.core.bridge.BridgeDelegate
16
+ import dev.hotwire.core.bridge.Message
17
+ import dev.hotwire.navigation.destinations.HotwireDestination
18
+ import kotlinx.serialization.SerialName
19
+ import kotlinx.serialization.Serializable
20
+
21
+ /**
22
+ * Bridge component backing `everywhere--haptics`: plays impact, notification and
23
+ * selection feedback for `Everywhere.haptics.*` calls from the page. Three
24
+ * events — `impact` `{style}`, `notification` `{type}`, `selection` `{}` — and
25
+ * no reply at all, exactly like iOS's HapticsComponent.swift.
26
+ *
27
+ * Impact and selection go through `View.performHapticFeedback`, which is the
28
+ * closest thing Android has to `UIImpactFeedbackGenerator`: the system picks the
29
+ * waveform, respects the user's haptics setting, and — the practical part — it
30
+ * needs **no VIBRATE permission**. The shell's manifest only declares the
31
+ * permissions everywhere.yml asked for, so anything that assumed VIBRATE would
32
+ * throw in most apps.
33
+ *
34
+ * Notification feedback has no `performHapticFeedback` analogue below API 30
35
+ * and no three-way one above it, so those three get real waveforms — but only
36
+ * when VIBRATE happens to be granted. `checkSelfPermission` on a normal
37
+ * permission is exactly a manifest check, which makes this a clean feature
38
+ * detect rather than a gamble; without it we fall back to the nearest constant.
39
+ * The patterns mirror the Vibration-API fallback in bridge.js, so a page feels
40
+ * the same shape of buzz in an Android browser and in the shell.
41
+ */
42
+ class HapticsComponent(
43
+ name: String,
44
+ private val delegate: BridgeDelegate<HotwireDestination>
45
+ ) : BridgeComponent<HotwireDestination>(name, delegate) {
46
+
47
+ private val view: View?
48
+ get() = delegate.destination.fragment.view
49
+
50
+ override fun onReceive(message: Message) {
51
+ when (message.event) {
52
+ "impact" -> impact(message.data<Payload>()?.style)
53
+ "notification" -> notification(message.data<Payload>()?.type)
54
+ "selection" -> perform(HapticFeedbackConstants.CLOCK_TICK)
55
+ else -> Log.w(TAG, "Unknown event for message: $message")
56
+ }
57
+ }
58
+
59
+ private fun impact(style: String?) {
60
+ perform(
61
+ when (style) {
62
+ // CLOCK_TICK is Android's lightest standard tick; LONG_PRESS is
63
+ // its heaviest. "soft"/"rigid" differ on iOS in sharpness rather
64
+ // than strength, which Android's constants don't express, so
65
+ // they map onto the nearest light and medium.
66
+ "light", "soft" -> HapticFeedbackConstants.CLOCK_TICK
67
+ "heavy" -> HapticFeedbackConstants.LONG_PRESS
68
+ "rigid" -> HapticFeedbackConstants.CONTEXT_CLICK
69
+ else -> HapticFeedbackConstants.VIRTUAL_KEY // medium
70
+ }
71
+ )
72
+ }
73
+
74
+ private fun notification(type: String?) {
75
+ val pattern = when (type) {
76
+ "error" -> longArrayOf(0, 30, 60, 30)
77
+ "warning" -> longArrayOf(0, 20, 40, 20)
78
+ else -> longArrayOf(0, 10, 30, 10) // success
79
+ }
80
+
81
+ if (vibrate(pattern)) return
82
+
83
+ // No VIBRATE: API 30 has purpose-built confirm/reject constants, and
84
+ // below that a plain key click is the most honest approximation.
85
+ perform(
86
+ when {
87
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> HapticFeedbackConstants.VIRTUAL_KEY
88
+ type == "error" -> HapticFeedbackConstants.REJECT
89
+ type == "warning" -> HapticFeedbackConstants.LONG_PRESS
90
+ else -> HapticFeedbackConstants.CONFIRM
91
+ }
92
+ )
93
+ }
94
+
95
+ private fun perform(constant: Int) {
96
+ view?.performHapticFeedback(constant)
97
+ }
98
+
99
+ /** @return false when there is no vibrator or no VIBRATE permission. */
100
+ private fun vibrate(pattern: LongArray): Boolean {
101
+ val context = view?.context ?: return false
102
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.VIBRATE)
103
+ != PackageManager.PERMISSION_GRANTED
104
+ ) {
105
+ return false
106
+ }
107
+
108
+ val vibrator = vibrator(context) ?: return false
109
+ if (!vibrator.hasVibrator()) return false
110
+
111
+ return try {
112
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1))
113
+ true
114
+ } catch (e: Exception) {
115
+ Log.w(TAG, "vibrate failed", e)
116
+ false
117
+ }
118
+ }
119
+
120
+ @Suppress("DEPRECATION")
121
+ private fun vibrator(context: Context): Vibrator? =
122
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
123
+ context.getSystemService(VibratorManager::class.java)?.defaultVibrator
124
+ } else {
125
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
126
+ }
127
+
128
+ /**
129
+ * One payload for all three events, matching iOS: `impact` sends `{style}`,
130
+ * `notification` sends `{type}`, `selection` sends `{}`.
131
+ */
132
+ @Serializable
133
+ data class Payload(
134
+ @SerialName("style") val style: String? = null,
135
+ @SerialName("type") val type: String? = null
136
+ )
137
+
138
+ private companion object {
139
+ const val TAG = "EverywhereHaptics"
140
+ }
141
+ }