<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