<lambda>null1 // Copyright 2015-present 650 Industries. All rights reserved.
2 package expo.modules.localauthentication
3
4 import android.app.Activity
5 import android.app.KeyguardManager
6 import android.content.Context
7 import android.os.Build
8 import android.os.Bundle
9 import androidx.annotation.UiThread
10 import androidx.biometric.BiometricManager
11 import androidx.biometric.BiometricPrompt
12 import androidx.biometric.BiometricPrompt.PromptInfo
13 import androidx.fragment.app.FragmentActivity
14 import expo.modules.kotlin.Promise
15 import expo.modules.kotlin.exception.Exceptions
16 import expo.modules.kotlin.exception.UnexpectedException
17 import expo.modules.kotlin.functions.Queues
18 import expo.modules.kotlin.modules.Module
19 import expo.modules.kotlin.modules.ModuleDefinition
20 import expo.modules.kotlin.records.Field
21 import expo.modules.kotlin.records.Record
22 import kotlinx.coroutines.launch
23 import java.util.concurrent.Executor
24 import java.util.concurrent.Executors
25
26 private const val AUTHENTICATION_TYPE_FINGERPRINT = 1
27 private const val AUTHENTICATION_TYPE_FACIAL_RECOGNITION = 2
28 private const val AUTHENTICATION_TYPE_IRIS = 3
29 private const val SECURITY_LEVEL_NONE = 0
30 private const val SECURITY_LEVEL_SECRET = 1
31 private const val SECURITY_LEVEL_BIOMETRIC = 2
32 private const val DEVICE_CREDENTIAL_FALLBACK_CODE = 6
33
34 class AuthOptions : Record {
35 @Field
36 val promptMessage: String = ""
37
38 @Field
39 val cancelLabel: String = ""
40
41 @Field
42 val disableDeviceFallback: Boolean = false
43
44 @Field
45 val requireConfirmation: Boolean = true
46 }
47
48 class LocalAuthenticationModule : Module() {
<lambda>null49 override fun definition() = ModuleDefinition {
50 Name("ExpoLocalAuthentication")
51
52 AsyncFunction("supportedAuthenticationTypesAsync") {
53 val results = mutableSetOf<Int>()
54 if (canAuthenticateUsingWeakBiometrics() == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
55 return@AsyncFunction results
56 }
57
58 // note(cedric): replace hardcoded system feature strings with constants from
59 // PackageManager when dropping support for Android SDK 28
60 results.apply {
61 addIf(hasSystemFeature("android.hardware.fingerprint"), AUTHENTICATION_TYPE_FINGERPRINT)
62 addIf(hasSystemFeature("android.hardware.biometrics.face"), AUTHENTICATION_TYPE_FACIAL_RECOGNITION)
63 addIf(hasSystemFeature("android.hardware.biometrics.iris"), AUTHENTICATION_TYPE_IRIS)
64 addIf(hasSystemFeature("com.samsung.android.bio.face"), AUTHENTICATION_TYPE_FACIAL_RECOGNITION)
65 }
66
67 return@AsyncFunction results
68 }
69
70 AsyncFunction("hasHardwareAsync") {
71 canAuthenticateUsingWeakBiometrics() != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
72 }
73
74 AsyncFunction("isEnrolledAsync") {
75 canAuthenticateUsingWeakBiometrics() == BiometricManager.BIOMETRIC_SUCCESS
76 }
77
78 AsyncFunction("getEnrolledLevelAsync") {
79 var level = SECURITY_LEVEL_NONE
80 if (isDeviceSecure) {
81 level = SECURITY_LEVEL_SECRET
82 }
83 if (canAuthenticateUsingWeakBiometrics() == BiometricManager.BIOMETRIC_SUCCESS) {
84 level = SECURITY_LEVEL_BIOMETRIC
85 }
86 return@AsyncFunction level
87 }
88
89 AsyncFunction("authenticateAsync") { options: AuthOptions, promise: Promise ->
90 val fragmentActivity = currentActivity as? FragmentActivity
91 if (fragmentActivity == null) {
92 promise.reject(Exceptions.MissingActivity())
93 return@AsyncFunction
94 }
95 if (!keyguardManager.isDeviceSecure) {
96 promise.resolve(
97 createResponse(
98 error = "not_enrolled",
99 warning = "KeyguardManager#isDeviceSecure() returned false"
100 )
101 )
102 return@AsyncFunction
103 }
104
105 this@LocalAuthenticationModule.authOptions = options
106
107 // BiometricPrompt callbacks are invoked on the main thread so also run this there to avoid
108 // having to do locking.
109 appContext.mainQueue.launch {
110 authenticate(fragmentActivity, options, promise)
111 }
112 }
113
114 AsyncFunction<Unit>("cancelAuthenticate") {
115 biometricPrompt?.cancelAuthentication()
116 isAuthenticating = false
117 }.runOnQueue(Queues.MAIN)
118
119 OnActivityResult { activity, (requestCode, resultCode, data) ->
120 if (requestCode == DEVICE_CREDENTIAL_FALLBACK_CODE) {
121 if (resultCode == Activity.RESULT_OK) {
122 promise?.resolve(createResponse())
123 } else {
124 promise?.resolve(
125 createResponse(
126 error = "user_cancel",
127 warning = "Device Credentials canceled"
128 )
129 )
130 }
131
132 isAuthenticating = false
133 isRetryingWithDeviceCredentials = false
134 biometricPrompt = null
135 promise = null
136 authOptions = null
137 } else if (activity is FragmentActivity) {
138 // If the user uses PIN as an authentication method, the result will be passed to the `onActivityResult`.
139 // Unfortunately, react-native doesn't pass this value to the underlying fragment - we won't resolve the promise.
140 // So we need to do it manually.
141 val fragment = activity.supportFragmentManager.findFragmentByTag("androidx.biometric.BiometricFragment")
142 fragment?.onActivityResult(requestCode and 0xffff, resultCode, data)
143 }
144 }
145 }
146
147 private val context: Context
148 get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
149
150 private val keyguardManager: KeyguardManager
151 get() = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
152
153 private val currentActivity: Activity?
154 get() = appContext.currentActivity
155
<lambda>null156 private val biometricManager by lazy { BiometricManager.from(context) }
<lambda>null157 private val packageManager by lazy { context.packageManager }
158 private var biometricPrompt: BiometricPrompt? = null
159 private var promise: Promise? = null
160 private var authOptions: AuthOptions? = null
161 private var isRetryingWithDeviceCredentials = false
162 private var isAuthenticating = false
163
164 private val authenticationCallback: BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
onAuthenticationSucceedednull165 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
166 isAuthenticating = false
167 isRetryingWithDeviceCredentials = false
168 biometricPrompt = null
169 promise?.resolve(
170 Bundle().apply {
171 putBoolean("success", true)
172 }
173 )
174 promise = null
175 authOptions = null
176 }
177
onAuthenticationErrornull178 override fun onAuthenticationError(errMsgId: Int, errString: CharSequence) {
179 // Make sure to fallback to the Device Credentials if the Biometrics hardware is unavailable.
180 if (isBiometricUnavailable(errMsgId) && isDeviceSecure && !isRetryingWithDeviceCredentials) {
181 val options = authOptions
182
183 if (options != null) {
184 val disableDeviceFallback = options.disableDeviceFallback
185
186 // Don't run the device credentials fallback if it's disabled.
187 if (!disableDeviceFallback) {
188 promise?.let {
189 isRetryingWithDeviceCredentials = true
190 promptDeviceCredentialsFallback(options, it)
191 return
192 }
193 }
194 }
195 }
196
197 isAuthenticating = false
198 isRetryingWithDeviceCredentials = false
199 biometricPrompt = null
200 promise?.resolve(
201 createResponse(
202 error = convertErrorCode(errMsgId),
203 warning = errString.toString()
204 )
205 )
206 promise = null
207 authOptions = null
208 }
209 }
210
211 @UiThread
authenticatenull212 private fun authenticate(fragmentActivity: FragmentActivity, options: AuthOptions, promise: Promise) {
213 if (isAuthenticating) {
214 this.promise?.resolve(
215 createResponse(
216 error = "app_cancel"
217 )
218 )
219 this.promise = promise
220 return
221 }
222
223 val promptMessage = options.promptMessage
224 val cancelLabel = options.cancelLabel
225 val disableDeviceFallback = options.disableDeviceFallback
226 val requireConfirmation = options.requireConfirmation
227
228 isAuthenticating = true
229 this.promise = promise
230 val executor: Executor = Executors.newSingleThreadExecutor()
231 biometricPrompt = BiometricPrompt(fragmentActivity, executor, authenticationCallback)
232 val promptInfoBuilder = PromptInfo.Builder().apply {
233 setTitle(promptMessage)
234
235 if (disableDeviceFallback) {
236 setNegativeButtonText(cancelLabel)
237 } else {
238 setAllowedAuthenticators(
239 BiometricManager.Authenticators.BIOMETRIC_WEAK
240 or BiometricManager.Authenticators.DEVICE_CREDENTIAL
241 )
242 }
243 setConfirmationRequired(requireConfirmation)
244 }
245
246 val promptInfo = promptInfoBuilder.build()
247 try {
248 biometricPrompt!!.authenticate(promptInfo)
249 } catch (e: NullPointerException) {
250 promise.reject(UnexpectedException("Canceled authentication due to an internal error", e))
251 }
252 }
253
promptDeviceCredentialsFallbacknull254 private fun promptDeviceCredentialsFallback(options: AuthOptions, promise: Promise) {
255 val fragmentActivity = currentActivity as FragmentActivity?
256 if (fragmentActivity == null) {
257 promise.resolve(
258 createResponse(
259 error = "not_available",
260 warning = "getCurrentActivity() returned null"
261 )
262 )
263 return
264 }
265
266 val promptMessage = options.promptMessage
267 val requireConfirmation = options.requireConfirmation
268
269 // BiometricPrompt callbacks are invoked on the main thread so also run this there to avoid
270 // having to do locking.
271 appContext.mainQueue.launch {
272 // On Android devices older than 11, we need to use Keyguard to unlock by Device Credentials.
273 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
274 val credentialConfirmationIntent = keyguardManager.createConfirmDeviceCredentialIntent(promptMessage, "")
275 fragmentActivity.startActivityForResult(credentialConfirmationIntent, DEVICE_CREDENTIAL_FALLBACK_CODE)
276 return@launch
277 }
278
279 val executor: Executor = Executors.newSingleThreadExecutor()
280 val localBiometricPrompt = BiometricPrompt(fragmentActivity, executor, authenticationCallback)
281
282 biometricPrompt = localBiometricPrompt
283
284 val promptInfoBuilder = PromptInfo.Builder().apply {
285 setTitle(promptMessage)
286 setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
287 setConfirmationRequired(requireConfirmation)
288 }
289
290 val promptInfo = promptInfoBuilder.build()
291 try {
292 localBiometricPrompt.authenticate(promptInfo)
293 } catch (e: NullPointerException) {
294 promise.reject(UnexpectedException("Canceled authentication due to an internal error", e))
295 }
296 }
297 }
298
hasSystemFeaturenull299 private fun hasSystemFeature(feature: String) = packageManager.hasSystemFeature(feature)
300
301 // NOTE: `KeyguardManager#isKeyguardSecure()` considers SIM locked state,
302 // but it will be ignored on falling-back to device credential on biometric authentication.
303 // That means, setting level to `SECURITY_LEVEL_SECRET` might be misleading for some users.
304 // But there is no equivalent APIs prior to M.
305 // `andriodx.biometric.BiometricManager#canAuthenticate(int)` looks like an alternative,
306 // but specifying `BiometricManager.Authenticators.DEVICE_CREDENTIAL` alone is not
307 // supported prior to API 30.
308 // https://developer.android.com/reference/androidx/biometric/BiometricManager#canAuthenticate(int)
309 private val isDeviceSecure: Boolean
310 get() = keyguardManager.isDeviceSecure
311
312 private fun convertErrorCode(code: Int): String {
313 return when (code) {
314 BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON, BiometricPrompt.ERROR_USER_CANCELED -> "user_cancel"
315 BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> "not_available"
316 BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> "lockout"
317 BiometricPrompt.ERROR_NO_SPACE -> "no_space"
318 BiometricPrompt.ERROR_TIMEOUT -> "timeout"
319 BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> "unable_to_process"
320 else -> "unknown"
321 }
322 }
323
isBiometricUnavailablenull324 private fun isBiometricUnavailable(code: Int): Boolean {
325 return when (code) {
326 BiometricPrompt.ERROR_HW_NOT_PRESENT,
327 BiometricPrompt.ERROR_HW_UNAVAILABLE,
328 BiometricPrompt.ERROR_NO_BIOMETRICS,
329 BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
330 BiometricPrompt.ERROR_NO_SPACE -> true
331
332 else -> false
333 }
334 }
335
canAuthenticateUsingWeakBiometricsnull336 private fun canAuthenticateUsingWeakBiometrics(): Int =
337 biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
338
339 private fun createResponse(
340 error: String? = null,
341 warning: String? = null
342 ) = Bundle().apply {
343 putBoolean("success", error == null)
344 error?.let {
345 putString("error", it)
346 }
347 warning?.let {
348 putString("warning", it)
349 }
350 }
351 }
352
addIfnull353 fun <T> MutableSet<T>.addIf(condition: Boolean, valueToAdd: T) {
354 if (condition) {
355 add(valueToAdd)
356 }
357 }
358