├── .gitignore ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── tatarka │ │ └── biometricssample │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── me │ │ │ └── tatarka │ │ │ └── biometricssample │ │ │ ├── Crypto.kt │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── me │ └── tatarka │ └── biometricssample │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Evan Tatarka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Biometric Sample 2 | 3 | A sample implementation of the androidx biometric compat lib with all the workarounds needed for a 4 | production app. [MainActivity.kt](app/src/main/java/me/tatarka/biometricssample/MainActivity.kt) 5 | includes the logic to show the biometric prompt both at startup and on the click of a button. 6 | [Cyrpto.kt](app/src/main/java/me/tatarka/biometricssample/Crypto.kt) handles encrypting and 7 | decrypting data using asymmetric encryption. Both are heavily commented. -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | android { 8 | compileSdkVersion 29 9 | defaultConfig { 10 | applicationId "me.tatarka.biometricscompatissue" 11 | minSdkVersion 23 12 | targetSdkVersion 29 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 27 | implementation 'androidx.appcompat:appcompat:1.1.0' 28 | implementation 'androidx.core:core-ktx:1.1.0' 29 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 30 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' 31 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-rc03' 32 | implementation 'androidx.biometric:biometric:1.0.1' 33 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0" 34 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0" 35 | testImplementation 'junit:junit:4.12' 36 | androidTestImplementation 'androidx.test:runner:1.2.0' 37 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 38 | } 39 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/me/tatarka/biometricssample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.tatarka.biometricssample 2 | 3 | import androidx.test.InstrumentationRegistry 4 | import androidx.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getTargetContext() 22 | assertEquals("me.tatarka.biometricscompatissue", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/me/tatarka/biometricssample/Crypto.kt: -------------------------------------------------------------------------------- 1 | package me.tatarka.biometricssample 2 | 3 | import android.security.keystore.KeyGenParameterSpec 4 | import android.security.keystore.KeyProperties 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import java.nio.ByteBuffer 8 | import java.security.* 9 | import java.security.spec.MGF1ParameterSpec 10 | import java.security.spec.X509EncodedKeySpec 11 | import javax.crypto.Cipher 12 | import javax.crypto.KeyGenerator 13 | import javax.crypto.spec.GCMParameterSpec 14 | import javax.crypto.spec.OAEPParameterSpec 15 | import javax.crypto.spec.PSource 16 | 17 | /* 18 | Asymmetric encryption using key wrapping with RSA and AES. This allows you to encrypt data 19 | without asking for biometrics, while requiring it to decrypt. 20 | */ 21 | 22 | private val KEY_ALIAS = "key" 23 | private val AES_KEY_SIZE = 128 24 | private val AES_CIPHER = "AES/GCM/NoPadding" 25 | // The device ui freezes while the key is being generated (yes even though it's on a background thread), 26 | // this is as big as we can go before its very noticeable. 27 | private val RSA_KEY_SIZE = 1024 28 | private val RSA_CIPHER = "RSA/ECB/OAEPWithSHA-512AndMGF1Padding" 29 | 30 | private val keyStore by lazy(LazyThreadSafetyMode.NONE) { 31 | KeyStore.getInstance("AndroidKeyStore").apply { load(null) } 32 | } 33 | 34 | private val aesCipher by lazy(LazyThreadSafetyMode.NONE) { 35 | Cipher.getInstance(AES_CIPHER) 36 | } 37 | 38 | private val rsaCipher by lazy(LazyThreadSafetyMode.NONE) { 39 | Cipher.getInstance(RSA_CIPHER) 40 | } 41 | 42 | /** 43 | * Generate a symmetric AES key 44 | */ 45 | private fun createKey() = KeyGenerator.getInstance("AES").apply { init(AES_KEY_SIZE) }.generateKey() 46 | 47 | /** 48 | * Generate an asymmetric RSA key pair 49 | */ 50 | private fun createKeyPair(keyStore: KeyStore) = 51 | KeyPairGenerator.getInstance("RSA", keyStore.provider).apply { 52 | initialize( 53 | KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT) 54 | .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) 55 | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) 56 | .setKeySize(RSA_KEY_SIZE) 57 | .setUserAuthenticationRequired(true) 58 | .build() 59 | ) 60 | }.generateKeyPair() 61 | 62 | /** 63 | * Gets an RSA cipher, applying work-arounds for bugs on api 23. 64 | */ 65 | private fun rsaCipher(keyStore: KeyStore, opmode: Int) = rsaCipher.apply { 66 | /* 67 | A known bug in the Android 6.0 (API Level 23) implementation of Bouncy Castle 68 | RSA OAEP causes the cipher to default to an SHA-1 certificate, making the SHA-256 69 | certificate of the public key incompatible 70 | To work around this issue, explicitly provide a new OAEP specification upon 71 | initialization 72 | https://code.google.com/p/android/issues/detail?id=197719 73 | */ 74 | val spec = 75 | OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT) 76 | val key = if (opmode == Cipher.WRAP_MODE) { 77 | val publicKey = publicKey(keyStore) 78 | /* 79 | A known bug in Android 6.0 (API Level 23) causes user authentication-related 80 | authorizations to be enforced even for public keys 81 | To work around this issue, extract the public key material to use outside of 82 | the Android Keystore 83 | http://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.html 84 | */ 85 | KeyFactory.getInstance(publicKey.algorithm) 86 | .generatePublic(X509EncodedKeySpec(publicKey.encoded)) 87 | } else { 88 | privateKey(keyStore) 89 | } 90 | init(opmode, key, spec) 91 | } 92 | 93 | /** 94 | * Gets or creates the RSA public key 95 | */ 96 | private fun publicKey(keyStore: KeyStore): PublicKey { 97 | if (keyStore.containsAlias(KEY_ALIAS)) { 98 | val certificate = keyStore.getCertificate(KEY_ALIAS) 99 | if (certificate != null) { 100 | return certificate.publicKey 101 | } else { 102 | keyStore.deleteEntry(KEY_ALIAS) 103 | } 104 | } 105 | return createKeyPair(keyStore).public 106 | } 107 | 108 | /** 109 | * Gets or creates the RSA private key 110 | */ 111 | private fun privateKey(keyStore: KeyStore): PrivateKey { 112 | if (keyStore.containsAlias(KEY_ALIAS)) { 113 | val key = try { 114 | keyStore.getKey(KEY_ALIAS, null) 115 | } catch (e: GeneralSecurityException) { 116 | keyStore.deleteEntry(KEY_ALIAS) 117 | throw GeneralSecurityException(e) 118 | } 119 | if (key is PrivateKey) { 120 | return key 121 | } else { 122 | keyStore.deleteEntry(KEY_ALIAS) 123 | } 124 | } 125 | return createKeyPair(keyStore).private 126 | } 127 | 128 | /** 129 | * Encrypts the given input. This does not require the user to unlock the key with biometrics as 130 | * only the public key is used. 131 | */ 132 | suspend fun encrypt(input: ByteArray): ByteArray { 133 | return withContext(Dispatchers.IO) { 134 | // RSA(key) + iv + AES(key, input) 135 | val key = createKey() 136 | val (encryptedInput, iv) = aesCipher.run { 137 | init(Cipher.ENCRYPT_MODE, key) 138 | doFinal(input) to iv 139 | } 140 | val cipher = rsaCipher(keyStore, Cipher.WRAP_MODE) 141 | val encryptedKey = cipher.wrap(key) 142 | val output = 143 | ByteBuffer.allocate(4 + encryptedKey.size + 4 + iv.size + encryptedInput.size).apply { 144 | putInt(encryptedKey.size) 145 | put(encryptedKey) 146 | putInt(iv.size) 147 | put(iv) 148 | put(encryptedInput) 149 | } 150 | output.array() 151 | } 152 | } 153 | 154 | /** 155 | * Decrypts the given input. The authenticator callback should unlock the cipher by prompting the 156 | * user for biometrics. 157 | */ 158 | suspend fun decrypt(input: ByteArray, authenticator: suspend (Cipher) -> Cipher): ByteArray { 159 | return withContext(Dispatchers.IO) { 160 | val lockedCipher = rsaCipher(keyStore, Cipher.UNWRAP_MODE) 161 | val cipher = withContext(Dispatchers.Main) { authenticator(lockedCipher) } 162 | // No data to decrypt 163 | if (input.isEmpty()) { 164 | return@withContext ByteArray(0) 165 | } 166 | ByteBuffer.wrap(input).run { 167 | val keyLength = getInt() 168 | val encryptedKey = ByteArray(keyLength).also { get(it) } 169 | val ivLength = getInt() 170 | val iv = ByteArray(ivLength).also { get(it) } 171 | val encryptedOutput = 172 | ByteArray(input.size - keyLength - 4 - ivLength - 4).also { get(it) } 173 | val aesKey = cipher.unwrap(encryptedKey, "AES", Cipher.SECRET_KEY) 174 | aesCipher.apply { init(Cipher.DECRYPT_MODE, aesKey, GCMParameterSpec(128, iv)) } 175 | .doFinal(encryptedOutput) 176 | } 177 | } 178 | } 179 | 180 | -------------------------------------------------------------------------------- /app/src/main/java/me/tatarka/biometricssample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.tatarka.biometricssample 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.security.keystore.KeyGenParameterSpec 7 | import android.security.keystore.KeyProperties 8 | import android.util.Base64 9 | import android.util.Log 10 | import android.view.View 11 | import android.view.ViewTreeObserver 12 | import androidx.appcompat.app.AlertDialog 13 | import androidx.appcompat.app.AppCompatActivity 14 | import androidx.biometric.BiometricManager 15 | import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS 16 | import androidx.biometric.BiometricPrompt 17 | import androidx.core.content.ContextCompat 18 | import androidx.lifecycle.lifecycleScope 19 | import kotlinx.android.synthetic.main.activity_main.* 20 | import kotlinx.coroutines.launch 21 | import java.security.GeneralSecurityException 22 | import java.security.InvalidAlgorithmParameterException 23 | import java.security.KeyException 24 | import java.security.KeyStore 25 | import javax.crypto.KeyGenerator 26 | import kotlin.coroutines.resume 27 | import kotlin.coroutines.resumeWithException 28 | import kotlin.coroutines.suspendCoroutine 29 | 30 | class MainActivity : AppCompatActivity() { 31 | 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | setContentView(R.layout.activity_main) 35 | 36 | text.text = "Api: " + Build.VERSION.SDK_INT 37 | 38 | button.setOnClickListener { 39 | showBiometricPrompt() 40 | } 41 | 42 | encrypt.setOnClickListener { 43 | lifecycleScope.launch { 44 | try { 45 | val encryptedData = encrypt(data.text.toString().toByteArray()) 46 | encrypted.text = Base64.encodeToString(encryptedData, 0) 47 | } catch (e: GeneralSecurityException) { 48 | AlertDialog.Builder(this@MainActivity) 49 | .setTitle("Biometrics Error") 50 | .setMessage(e.message) 51 | .setPositiveButton("Ok", null) 52 | .show() 53 | } 54 | } 55 | } 56 | } 57 | 58 | override fun onResume() { 59 | super.onResume() 60 | 61 | if (canSecurelyAuthenticate(applicationContext)) { 62 | // Showing the biometrics prompt will be ignored if the app does not have focus. You may 63 | // think that this will always be the case if you are resumed, it is not. 64 | if (hasWindowFocus()) { 65 | showBiometricPrompt() 66 | } else { 67 | window.decorView.viewTreeObserver.addOnWindowFocusChangeListener(object : 68 | ViewTreeObserver.OnWindowFocusChangeListener { 69 | override fun onWindowFocusChanged(hasFocus: Boolean) { 70 | if (hasFocus) { 71 | window.decorView.viewTreeObserver.removeOnWindowFocusChangeListener( 72 | this 73 | ) 74 | showBiometricPrompt() 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | } 81 | 82 | private fun showProgress(shown: Boolean) { 83 | progress.visibility = if (shown) View.VISIBLE else View.INVISIBLE 84 | } 85 | 86 | private fun showBiometricPrompt() { 87 | // The coroutine stuff is to make sure crypto is preformed on a background thread while the 88 | // prompt is shown on the main thread. 89 | lifecycleScope.launch { 90 | try { 91 | val decryptedData = decrypt(Base64.decode(encrypted.text.toString(), 0)) { cipher -> 92 | // On api 28 if the user is locked out of biometrics from too many failed 93 | // attempts there will be a long delay before getting the error back. So the 94 | // user isn't confused as to what is going on, show a loading indicator. 95 | if (Build.VERSION.SDK_INT == 28) { 96 | showProgress(true) 97 | 98 | // The only way to tell that the prompt is shown is to listen to the window 99 | // losing focus events. 100 | window.decorView.viewTreeObserver.addOnWindowFocusChangeListener(object : 101 | ViewTreeObserver.OnWindowFocusChangeListener { 102 | override fun onWindowFocusChanged(hasFocus: Boolean) { 103 | if (!hasFocus) { 104 | window.decorView.viewTreeObserver.removeOnWindowFocusChangeListener( 105 | this 106 | ) 107 | showProgress(false) 108 | } 109 | } 110 | }) 111 | } 112 | 113 | suspendCoroutine { continuation -> 114 | BiometricPrompt( 115 | this@MainActivity, 116 | // Run callbacks on the main thread 117 | ContextCompat.getMainExecutor(this@MainActivity), 118 | object : BiometricPrompt.AuthenticationCallback() { 119 | private var authenticationFailed = false 120 | 121 | override fun onAuthenticationError( 122 | errorCode: Int, 123 | errString: CharSequence 124 | ) { 125 | val shouldShow = 126 | !isCancel(errorCode) && !authenticationFailed 127 | continuation.resumeWithException( 128 | BiometricException( 129 | errorCode, 130 | errString, 131 | shouldShow 132 | ) 133 | ) 134 | } 135 | 136 | override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { 137 | continuation.resume(result.cryptoObject!!.cipher!!) 138 | } 139 | 140 | override fun onAuthenticationFailed() { 141 | // This means the dialog was shown, so we don't want to show the 142 | // error again ourselves 143 | authenticationFailed = true 144 | } 145 | 146 | /** 147 | * If the prompt was canceled by the user we don't want to show an error ourselves 148 | */ 149 | fun isCancel(errorCode: Int) = 150 | errorCode == BiometricPrompt.ERROR_USER_CANCELED 151 | || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON 152 | 153 | }).authenticate( 154 | BiometricPrompt.PromptInfo.Builder() 155 | .setTitle("Title") 156 | .setDescription("Description") 157 | .setNegativeButtonText("Cancel") 158 | .build(), 159 | // A CryptoObject is required to ensure 'secure' biometrics. Even if you don't need to 160 | // unlock what you pass here, you need to pass something or the device may use a 161 | // different weaker form of biometrics. 162 | BiometricPrompt.CryptoObject(cipher) 163 | ) 164 | } 165 | } 166 | decrypted.text = String(decryptedData) 167 | } catch (e: BiometricException) { 168 | // Hide the previously shown loading indicator if there's an error. 169 | if (Build.VERSION.SDK_INT == 28) { 170 | showProgress(false) 171 | } 172 | if (e.shouldShow) { 173 | AlertDialog.Builder(this@MainActivity) 174 | .setTitle("Biometrics Error") 175 | .setMessage(e.errString) 176 | .setPositiveButton("Ok", null) 177 | .show() 178 | } 179 | } catch (e: GeneralSecurityException) { 180 | AlertDialog.Builder(this@MainActivity) 181 | .setTitle("Biometrics Error") 182 | .setMessage(e.message) 183 | .setPositiveButton("Ok", null) 184 | .show() 185 | } 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * Checks if we can securely authenticate, i.e. we have secure biometrics hardware and the user can 192 | * enroll. [androidx.biometric.BiometricManager.canAuthenticate] is insufficient for this because 193 | * on api 29+ it checks for any form of biometrics, not just ones that are 'secure', so we can 194 | * get a false-positive. 195 | */ 196 | private fun canSecurelyAuthenticate(context: Context): Boolean { 197 | if (Build.VERSION.SDK_INT < 23) { 198 | return false 199 | } 200 | try { 201 | val keystore = KeyStore.getInstance("AndroidKeyStore") 202 | KeyGenerator.getInstance("AES", keystore.provider) 203 | .init( 204 | KeyGenParameterSpec.Builder("DUMMY_KEY_ALIAS", KeyProperties.PURPOSE_DECRYPT) 205 | .setUserAuthenticationRequired(true) 206 | .build() 207 | ) 208 | // On API 24 & 25 regardless of enrollment, as well as devices on API < 29 that have other 209 | // forms of biometrics enrolled eg. Samsung's iris scan, the above will not throw, but 210 | // BiometricPrompt will still throw an error when shown. Check the biometric manager as a fallback. 211 | return BiometricManager.from(context).canAuthenticate() == BIOMETRIC_SUCCESS 212 | } catch (e: InvalidAlgorithmParameterException) { 213 | // expected error if user isn't enrolled in secure biometrics 214 | return false 215 | } catch (e: Exception) { 216 | // Log unexpected errors, though if there's an issue with the keystore we probably can't use 217 | // biometrics anyway. 218 | Log.w("BiometricSample", e) 219 | return false 220 | } 221 | } 222 | 223 | class BiometricException( 224 | val code: Int, 225 | val errString: CharSequence, 226 | /** 227 | * If true, we need to show the error to the user. 228 | */ 229 | val shouldShow: Boolean 230 | ) : Exception("$errString ($code)") 231 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 28 | 29 | 39 | 40 |