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/BiometricsComponent.kt
ADDED
|
@@ -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
|
+
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt
ADDED
|
@@ -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
|
+
}
|