Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Engineering Biometric UX Across Android Version...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →
Avatar for RyuNen344 RyuNen344
November 02, 2026

Engineering Biometric UX Across Android Versions | DroidKnights 2026

Avatar for RyuNen344

RyuNen344

November 02, 2026

More Decks by RyuNen344

Other Decks in Programming

Transcript

  1. ✦ DroidKnights 2026 Engineering Biometric UX Across Android Versions Bunjiro

    Miyoshi / RyuNen344 MIXI, Inc. · Android App Developer 2026.11.02 · ST Center, Seoul
  2. ✦ Speaker About Me Android app developer — MIXI, Inc.

    Online — RyuNen344 on GitHub, X, and everywhere else Likes — Kodee. Three big plushies at home. 02
  3. ✦ W h y I t M a tt e

    r s Why Biometric UX Matters IDENTITY KEYS WALLET mDL Passkeys Tap to pay ISO mdoc. Your driver's license, on the phone FIDO2 / WebAuthn. Sign in without passwords Hold the phone to the terminal All three stand on biometrics and the Android KeyStore. Browsers can't call them. Only apps can. 03
  4. ✦ My Story A Convenience Store Story Face unlock NFC

    · Google Wallet Fingerprint Nothing Phone (3) 04
  5. ✦ My Story Lock, Cover, Fingerprint, Tap WHAT HAPPENS NFC

    wakes the Tap WHAT phone I Lock DO Face unlock Payment fails Fingerprint Tap. Paid. NOW Hide the front camera Why is my face the wrong unlock here? The spec explains it. 05
  6. ✦ The Rulebook The Rulebook: Android CDD Compatibility Definition Document.

    What a device must meet, per API level, to count as Android-compatible. CDD §7.3.10 CDD §9.11 Biometric Sensors Keys and Credentials Sensor classes, spoof and imposter limits, enrollment rules Hardware-backed keystore, biometricbound keys, auth validity Read the same section across versions, API 23 to 37. The requirements move. 06
  7. ✦ The Rulebook Not All Biometrics Are Equal CLASS 1

    CLASS 2 CLASS 3 Convenience Weak Strong Unlocks the screen. Nothing more. Can authenticate you inside apps. Can unlock keys in the Android KeyStore. Three classes since API 29. Users don't know. 07
  8. ✦ The Rulebook From "Fingerprints" to "Biometric Enrollments" 23 24

    25 fingerprints 26 27 28 29 30 31 32 33 34 35 36 37 biometric enrollments §7.3.10.2 Other Biometric Sensors added Other modalities went mainstream. 08
  9. ✦ The Rulebook Apps Can't Tell Fingers Apart 23 24

    25 26 27 28 29 30 31 32 33 34 35 36 37 MUST NOT distinguish individual biometrics Never. API 23 → 37. Which one? The app can't tell. Apps can't tell which one. Users don't know. 09
  10. ✦ The Rulebook Class 2 Wasn't Always Available to Apps

    23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 MUST be available to apps no classes yet apps can't use it [C-2-9] added On API 29 and 30, apps may not get Class 2. Users don't know. 10
  11. ✦ History Before Marshmallow: One SDK per Vendor Samsung Pass

    SDK Your app Vendor B SDK Vendor C SDK Android OS device credential screen only Before API 23: one SDK per vendor. 11
  12. ✦ History Marshmallow to R: FingerprintManager to BiometricPrompt 23 FingerprintManager

    One fingerprint API for every vendor 26 72h primary auth PIN, pattern or password every 72 hours 28 BiometricPrompt The OS draws the prompt 29 setDeviceCredentialAllowed Credential fallback, but not with crypto 30 setAllowedAuthenticators Pick Class 3, Class 2, or device credential Before API 30, you couldn't pick the biometric class. 12
  13. ✦ History S to Cinnamon Bun: Still Evolving 33 PresentationSession

    mDL: present multiple credentials 35 PromptContentView Say why you're asking 36.1 CryptoObject(KeyAgreement) Bind key agreement to biometrics 37 getBiometricSensorStrengths Class per modality (Wallet / DPM only) 37.2 EmbeddedBiometricPrompt Embedded prompt. mDL moves to KeyStore. BiometricPrompt keeps growing for new use cases. 13
  14. ✦ Keys Why Biometrics? To Unlock Keys PIN Finger Face

    Android KeyStore Iris Every way of proving it's you leads to a key. 14
  15. ✦ Keys Only Class 3 Can Unlock Keys Class Class

    Class 3 PIN Android KeyStore §7.3.10 [C-6-1] [C-6-2] §9.11 [C-1-3] Only Class 3, or device credential, can unlock a key. 15
  16. ✦ Keys Biometrics and KeyStore: The Simple Table Auth-per-use key

    Auth on every use Time-based key Open for N sec after auth Class 1 Class 2 Class 3 Device credential API 30+ 16
  17. ✦ Keys Pitfall 1: Apps Can't Request Class 1 On

    the device What apps can ask for Class 3 BIOMETRIC_STRONG Class 2 BIOMETRIC_WEAK Class 1 BIOMETRIC_CONVENIENCE @hide @SystemApi CDD §7.3.10 [C-4-1] [C-4-2] Class 1 never reaches apps. Users don't know. 17
  18. ✦ Keys Pitfall 2: No Credential Fallback Before Android 11

    android/hardware/biometrics/BiometricPrompt.java · API 29 public void authenticate(@NonNull CryptoObject crypto, …) { // … if (mBundle.getBoolean(KEY_ALLOW_DEVICE_CREDENTIAL)) { throw new IllegalArgumentException( "Device credential not supported with crypto"); } } No device credential fallback for per-use keys before Android 11. Users don't know. 18
  19. ✦ Keys Pitfall 3: How You Unlocked Matters HOW YOU

    UNLOCKED PHONE KEYSTORE KEY Key unlocked Credential or Class 3 App opens Unlocked Class 1 or 2 Same for the user Key still locked Asks again CDD §9.11 [C-1-3] · §9.11.1 [C-5-3] Same unlock to the user. Different result in the app. Users don't know. 19
  20. ✦ Keys When Keys Break Biometric only Lock screen off

    Credential only Lock screen off Biometric + credential Lock screen off KeyStore2ParameterUtils.java · Biometric added All biometrics removed API 30+ // The biometric-only SIDs will change on biometric enrollment // or removal of all enrolled templates, invalidating the key. Change a biometric, and the key may be gone. Users don't know. 20
  21. ✦ Jetpack 1.1.0 Under the Hood YOUR APP ANDROIDX.BIOMETRIC Your

    Activity BiometricPrompt or Your Fragment BiometricFragment BiometricViewModel callback 2 CancellationSignal isPromptShowing 1 The state lives where your app can't reach it. 22
  22. ✦ Jetpack Device Names, Hard-Coded androidx.biometric · res/values/values.xml <string-array name="crypto_fingerprint_fallback_vendors">

    <item>samsung</item> </string-array> <string-array name="hide_fingerprint_instantly_prefixes"> <item>SM-A50</item> <!-- Samsung Galaxy A50 --> <item>SM-G973</item> <!-- Samsung Galaxy S10 --> <!-- … 15 more --> </string-array> <string-array name="delay_showing_prompt_models"> <item>Pixel 4</item> <item>Pixel 4 XL</item> </string-array> <string-array name="assume_strong_biometrics_models"> <item>Pixel 4</item> <item>Pixel 4 XL</item> </string-array> API 28 only API 28 only API 29 only below API 30 23
  23. ✦ Jetpack 1.2.0-alpha: Hello, Coroutines androidx/biometric/auth/Class3BiometricAuthExtensions.kt · biometric-ktx 1.2.0-alpha05 public

    suspend fun Class3BiometricAuthPrompt.authenticate( host: AuthPromptHost, crypto: CryptoObject?): AuthenticationResult { return suspendCancellableCoroutine { continuation -> val authPrompt = startAuthentication(host, crypto, Runnable::run, CoroutineAuthPromptCallback(continuation)) continuation.invokeOnCancellation { authPrompt.cancelAuthentication() } } } It looks clean. But it has two problems. 24
  24. ✦ Jetpack Problem 1: onAuthenticationFailed Isn't One-Shot androidx/biometric/auth/CoroutineAuthPromptCallback.kt · biometric-ktx

    1.2.0-alpha05 internal class CoroutineAuthPromptCallback( private val continuation: CancellableContinuation<AuthenticationResult> ) : AuthPromptCallback() { // onAuthenticationError(…) → resumeWithException(…) // onAuthenticationSucceeded(…) → resumeWith(…) override fun onAuthenticationFailed(activity: FragmentActivity?) { continuation.resumeWithException(AuthPromptFailureException()) } } One failure ends the coroutine. The prompt stays open. 25
  25. ✦ Jetpack Problem 2: lifecycleScope Cancels on Rotation Rotate BiometricPrompt

    android.hardware.biometrics via lifecycleScope biometric-ktx Prompt open Prompt still open Prompt open Prompt closes cancelAuthentication() Rotate the screen, and the prompt is gone. 26
  26. ✦ Platform Say Why You're Asking In API 35, PromptContentView

    puts what you're signing into the prompt. 28
  27. ✦ Platform Read, Then Authenticate In API 37.2, EmbeddedBiometricPrompt lets

    your app decide when authentication starts. But time-based keys only. 29
  28. ✦ Jetpack 1.4.0-alpha: Jetpack Biometric Is Back TransferActivity.kt private val

    auth = registerForAuthenticationResult { result -> when (result) { is AuthenticationResult.Success -> transfer() is AuthenticationResult.Error -> showError(result.errString) is AuthenticationResult.CustomFallbackSelected -> Unit } } private fun onTransferClick() { val request = biometricRequest("Transfer your pass", DeviceCredential) {} auth.launch(request) } 30
  29. ✦ Jetpack 1.4.0-alpha: Jetpack Biometric Is Back TransferScreen.kt @Composable fun

    TransferButton() { val auth = rememberAuthenticationLauncher { result -> when (result) { is AuthenticationResult.Success -> transfer() is AuthenticationResult.Error -> showError(result.errString) is AuthenticationResult.CustomFallbackSelected -> Unit } } val request = biometricRequest("Transfer your pass", DeviceCredential) {} Button(onClick = { auth.launch(request) }) { Text("Transfer") } } It's back. But it's still alpha and the internals got a lot more complex. 31
  30. ✦ Playbook Information Asymmetry USERS DON'T KNOW DEVELOPERS DON'T KNOW

    Never works Unlocks the phone, not the app Which biometric is Class 3 Stops working A new fingerprint breaks a biometric-only key The key broke, until it's used Fails right now Why it failed The real cause Users don't know. Developers don't know. The OS knows. 33
  31. ✦ Playbook The Obvious Answer: Raise minSdk Authenticator rules (API

    28–30) gone at 31+ Credential fallback with crypto (API 29) gone at 31+ Vendor workarounds (API 28/29) gone at 31+ Raise minSdk, and most of it goes away. Most. 34
  32. ✦ Playbook Fallback: Decide the Key Type Early REQUIREMENTS KEY

    TYPE FALLBACKS YOU CAN OFFER Auth-per-use Credential fallback: Android 11+ Security vs. UX Key type Time-based Credential fallback: any version Decide the key type first. Everything else follows. 35
  33. ✦ Playbook Own the Signal. Respect Rotation. 01 Own the

    CancellationSignal 02 Respect rotation You decide when the prompt closes, not lifecycleScope. The prompt outlives the Activity. So should your session. You close the prompt. Rotation doesn't. 36
  34. class BiometricAuthSession : ViewModel() { private var signal: CancellationSignal? =

    null private val executor = Executor { Handler(Looper.getMainLooper()).post(it) } private val _result = MutableStateFlow<AuthResult?>(null) val result: StateFlow<AuthResult?> = _result.asStateFlow() ✦ Playbook Own Your Auth Session The session owns the prompt: the signal and the result. It outlives the Activity. // It doesn't have to be a ViewModel. fun authenticate(prompt: BiometricPrompt, crypto: CryptoObject) { if (signal != null) return // the OS prompt is already showing val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, executor, Callback(signal)) } fun cancel() { signal?.cancel() signal = null } fun consumeResult() = _result.update { null } override fun onCleared() { cancel() // screen closed, not rotated } private inner class Callback(val current: CancellationSignal) : AuthenticationCallback() { override fun onAuthenticationSucceeded(r: AuthenticationResult) { if (current.isCanceled) return // a session we already canceled signal = null _result.update { AuthResult.Success(r.cryptoObject, r.authenticationType) } } // onAuthenticationError does the same with AuthResult.Failure } 37 }
  35. class BiometricAuthSession : ViewModel() { private var signal: CancellationSignal? =

    null private val executor = Executor { Handler(Looper.getMainLooper()).post(it) } private val _result = MutableStateFlow<AuthResult?>(null) val result: StateFlow<AuthResult?> = _result.asStateFlow() private var signal: CancellationSignal? = null ✦ Playbook Own Your Auth Session The session holds the CancellationSignal. Nothing else touches it. fun authenticate(prompt: BiometricPrompt, crypto: CryptoObject) { if (signal != null) return // the OS prompt is already showing val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, executor, Callback(signal)) } fun cancel() { signal?.cancel() signal = null } fun consumeResult() = _result.update { null } override fun onCleared() { cancel() // screen closed, not rotated } private inner class Callback(val current: CancellationSignal) : AuthenticationCallback() { override fun onAuthenticationSucceeded(r: AuthenticationResult) { if (current.isCanceled) return // a session we already canceled signal = null _result.update { AuthResult.Success(r.cryptoObject, r.authenticationType) } } // onAuthenticationError does the same with AuthResult.Failure } 38 }
  36. class BiometricAuthSession : ViewModel() { private var signal: CancellationSignal? =

    null private val executor = Executor { Handler(Looper.getMainLooper()).post(it) } private val _result = MutableStateFlow<AuthResult?>(null) val result: StateFlow<AuthResult?> = _result.asStateFlow() ✦ Playbook Own Your Auth Session authenticate creates the signal, keeps it, and hands it to the OS prompt. fun authenticate(prompt: BiometricPrompt, crypto: CryptoObject) { if (signal != null) return // the OS prompt is already showing val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, executor, Callback(signal)) } val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, …) fun cancel() { signal?.cancel() signal = null } fun consumeResult() = _result.update { null } override fun onCleared() { cancel() // screen closed, not rotated } private inner class Callback(val current: CancellationSignal) : AuthenticationCallback() { override fun onAuthenticationSucceeded(r: AuthenticationResult) { if (current.isCanceled) return // a session we already canceled signal = null _result.update { AuthResult.Success(r.cryptoObject, r.authenticationType) } } // onAuthenticationError does the same with AuthResult.Failure } 39 }
  37. class BiometricAuthSession : ViewModel() { private var signal: CancellationSignal? =

    null private val executor = Executor { Handler(Looper.getMainLooper()).post(it) } private val _result = MutableStateFlow<AuthResult?>(null) val result: StateFlow<AuthResult?> = _result.asStateFlow() ✦ Playbook Own Your Auth Session Cancel on the button and on close. Never on rotation. fun authenticate(prompt: BiometricPrompt, crypto: CryptoObject) { if (signal != null) return // the OS prompt is already showing val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, executor, Callback(signal)) } fun cancel() { signal?.cancel() signal = null } fun consumeResult() = _result.update { null } override fun onCleared() { cancel() // screen closed, not rotated } signal?.cancel() cancel() // screen closed, not rotated private inner class Callback(val current: CancellationSignal) : AuthenticationCallback() { override fun onAuthenticationSucceeded(r: AuthenticationResult) { if (current.isCanceled) return // a session we already canceled signal = null _result.update { AuthResult.Success(r.cryptoObject, r.authenticationType) } } // onAuthenticationError does the same with AuthResult.Failure } 40 }
  38. class BiometricAuthSession : ViewModel() { private var signal: CancellationSignal? =

    null private val executor = Executor { Handler(Looper.getMainLooper()).post(it) } private val _result = MutableStateFlow<AuthResult?>(null) val result: StateFlow<AuthResult?> = _result.asStateFlow() ✦ Playbook Own Your Auth Session The callback writes state. It never touches the Activity. fun authenticate(prompt: BiometricPrompt, crypto: CryptoObject) { if (signal != null) return // the OS prompt is already showing val signal = CancellationSignal() this.signal = signal prompt.authenticate(crypto, signal, executor, Callback(signal)) } fun cancel() { signal?.cancel() signal = null } fun consumeResult() = _result.update { null } override fun onCleared() { cancel() // screen closed, not rotated } private inner class Callback(val current: CancellationSignal) : AuthenticationCallback() { override fun onAuthenticationSucceeded(r: AuthenticationResult) { if (current.isCanceled) return // a session we already canceled signal = null _result.update { AuthResult.Success(r.cryptoObject, r.authenticationType) } } // onAuthenticationError does the same with AuthResult.Failure } 41 } _result.update { AuthResult.Success(…) }
  39. class PaymentActivity : ComponentActivity() { private val session: BiometricAuthSession by

    viewModels() // survives rotation override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) payButton.setOnClickListener { session.authenticate(buildPrompt(), crypto) } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { // re-collects after rotation session.result.filterNotNull().collect { r -> onAuthResult(r) } } } } ✦ Playbook Own Your Auth Session private fun buildPrompt() = BiometricPrompt.Builder(this) .setTitle("Confirm payment") .setNegativeButton("Cancel", mainExecutor) { _, _ -> session.cancel() } .build() The Activity starts the prompt and observes the result. private fun onAuthResult(r: AuthResult) { when (r) { is AuthResult.Success -> sign(r.crypto) is AuthResult.Failure -> showNextStep(r.errorCode) } session.consumeResult() } } 42
  40. class PaymentActivity : ComponentActivity() { private val session: BiometricAuthSession by

    viewModels() // survives rotation private val session: … by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) payButton.setOnClickListener { session.authenticate(buildPrompt(), crypto) } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { // re-collects after rotation session.result.filterNotNull().collect { r -> onAuthResult(r) } } } } ✦ Playbook repeatOnLifecycle(Lifecycle.State.STARTED) { Own Your Auth Session private fun buildPrompt() = BiometricPrompt.Builder(this) .setTitle("Confirm payment") .setNegativeButton("Cancel", mainExecutor) { _, _ -> session.cancel() } .build() The Activity only observes. Rotation just re-collects. private fun onAuthResult(r: AuthResult) { when (r) { is AuthResult.Success -> sign(r.crypto) is AuthResult.Failure -> showNextStep(r.errorCode) } session.consumeResult() } } 43
  41. ✦ Playbook Reading dumpsys adb shell dumpsys biometric · Nothing

    Phone (3) · Android 16 $ adb shell dumpsys biometric Legacy Settings: false Sensors: ID(0), oemStrength: 15, updatedStrength: 15, modality 2, state: 0, cookie: 0 ID(4), oemStrength: 4095, updatedStrength: 4095, modality 8, state: 0, cookie: 0 CurrentSession: null Spec sheets rarely say. The device in your hand does. 45
  42. ✦ Playbook Reading dumpsys: Strength adb shell dumpsys biometric ·

    Nothing Phone (3) · Android 16 Sensors: ID(0), oemStrength: 15, updatedStrength: 15, modality 2, state: 0, cookie: 0 ID(4), oemStrength: 4095, updatedStrength: 4095, modality 8, state: 0, cookie: 0 oemStrength / updatedStrength 15 0x000F BIOMETRIC_STRONG Class 3 255 0x00FF BIOMETRIC_WEAK Class 2 BIOMETRIC_CONVENIENCE Class 1 4095 0x0FFF 46
  43. ✦ Playbook Reading dumpsys: Modality adb shell dumpsys biometric ·

    Nothing Phone (3) · Android 16 Sensors: ID(0), oemStrength: 15, updatedStrength: ID(4), oemStrength: 4095, 15, updatedStrength: modality 4095, 2, modality state: 8, 0, state: cookie: 0, 0 cookie: 0 modality 2 TYPE_FINGERPRINT Fingerprint 4 TYPE_IRIS Iris 8 TYPE_FACE Face 47
  44. ✦ Playbook Mystery Solved: My Face Is Class 1 adb

    shell dumpsys biometric · Nothing Phone (3) · Android 16 Sensors: ID(0), oemtrength: 1, updatedtrength: 1, modality , state: 0, cookie: 0 ID(4), oemStrength: 4095, updatedStrength: 4095, modality 8, state: 0, cookie: 0 Face Class 1 BiometricPrompt Fingerprint Class 3 BiometricPrompt Class 1 unlocks the phone. Apps only get Class 2 and 3. 48
  45. ✦ Playbook Retry: When the Prompt Closes Try again User

    cancelled Needs settings or credential Nothing the app can do A button to try again A link to settings, or device credential Nothing. It's not an error. Another way to continue 49
  46. ✦ Playbook Retry: When the Prompt Closes Try again A

    button to try again User cancelled LOCKOUT TIMEOUT CANCELED HW_UNAVAILABLE UNABLE_TO_PROCESS Nothing. It's not an error. USER_CANCELED Needs settings or credential Nothing the app can do NO_BIOMETRICS NO_EVICE_CREENTIA LOCKOUT_PERMANENT NOT_ENABLE_OR_APPS HW_NOT_PRESENT VENOR A link to settings, or device credential Another way to continue SECURIT_UPATE_REUIRED 50
  47. ✦ Playbook Retry: When the Prompt Closes Try again A

    button to try again LCUEUCANCELED _UNAALALEUNALE__RCESS User cancelled Nothing. It's not an error. USER_CANCELED Negative button → its own listener Needs settings or credential Nothing the app can do N_ERCSN_ECE_CREENA LCU_ERANENN_ENALE_R_AS _N_RESENSECUR_UAE_REURED ENR A link to settings, or device credential Another way to continue 51
  48. ✦ Playbook Retry: When the Prompt Closes Try again User

    cancelled LOCKOUT TIMEOUT CANCELE _UNAVAILABLE UNABLE_TO_PROCESS USER_CANCELE A button to try again Needs settings or credential A link to settings, or device credential NO_BIOMETRICS NO_DEVICE_CREDENTIAL LOCKOUT_PERMANENT NOT_ENABLED_FOR_APPS Nothing. It's not an error. Nothing the app can do Another way to continue _NOT_PRESENT VENDOR SECURIT_UPDATE_REUIRE 52
  49. ✦ Playbook Retry: When the Prompt Closes Try again User

    cancelled OCOUT TIEOUT CANCEED HW_UNAVAIAE UNAE_TO_PROCESS USER_CANCEED A button to try again Needs settings or credential A link to settings, or device credential NO_IOETRICS NO_DEVICE_CREDENTIA OCOUT_PERANENT NOT_ENAED_OR_APPS Nothing. It's not an error. Nothing the app can do Another way to continue HW_NOT_PRESENT VENDOR SECURITY_UPDATE_REQUIRED 53
  50. ✦ Playbook Retry: KeyStore Exceptions KeyFailure.kt fun handle(e: Exception) =

    when (e) { is UserNotAuthenticatedException -> { ... } // Authenticate again is KeyPermanentlyInvalidatedException, is KeyExpiredException, is UnrecoverableKeyException -> { ... } // Re-setup is KeyNotYetValidException, is InvalidAlgorithmParameterException -> { ... } // Device settings is InvalidKeyException, ← catch last is ProviderException -> { ... } // Try again, then reboot } else -> throw e 54
  51. ✦ Playbook API 33+: KeyStoreException Tells You Why KeyFailure.kt ·

    API 33+ // android.security.KeyStoreException arrives as e.cause fun handle(e: KeyStoreException) = when { e.requiresUserAuthentication() -> { ... } // Authenticate again e.numericErrorCode == ERROR_KEY_CORRUPTED -> { ... } // Re-setup e.retryPolicy == RETRY_NEVER -> { ... } // Re-setup e.isTransientFailure -> { ... } // Try again with backoff else -> { ... } // Try again } 55
  52. ✦ Playbook The First Prompt Shouldn't Be at Checkout The

    key was created silently. The first prompt is the payment. 01 Key created silently 02 Weeks pass 03 First prompt: at checkout 04 Face fails 56
  53. ✦ Playbook Create the Key Visibly, Try It Once Explain,

    create, then try it once. The payment is the second prompt. 01 Explain what can break it 02 Create the key 03 Try it once 04 Checkout is the second prompt 57
  54. ✦ Playbook A Broken Key, Discovered at Checkout The key

    broke weeks ago. The user finds out at the register. 01 Weeks pass 02 Fingerprint added or screen lock off 03 Key breaks silently 04 Found at checkout “Biometrics don’t work.” — the user, to support 58
  55. ✦ Playbook A "Test My Biometrics" Screen Try anytime, with

    a test key. A broken key goes straight to re-setup. 01 Open from settings 02 Test key, same conditions 03 Broken? Set up again 04 Recent errors + device info for support 59
  56. ✦ Playbook We Know Nothing About Biometric Authentication Assume biometrics

    will fail. Assume keys will break. Retry anytime. Re-setup from day one. 61
  57. ✦ Playbook Wrap-up Read the CDD first — biometric requirements

    are written there Own your auth session — BiometricPrompt differs per OS; Jetpack can't absorb it all Check KeyStore per version — key handling and exceptions differ Design for the asymmetry — retry anytime, re-setup from day one ock cover fingerprint tap 62
  58. ✦ Playbook Wrap-up Read the CDD first — biometric requirements

    are written there Own your auth session — BiometricPrompt differs per OS; Jetpack can't absorb it all Check KeyStore per version — key handling and exceptions differ Design for the asymmetry — retry anytime, re-setup from day one P.S. My Nothing Phone still needs: Lock, cover, fingerprint, tap. 63
  59. ✦ Credits Notice The Android robot is reproduced or modified

    from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License. Kodee by JetBrains s.r.o. is licensed under CC BY 4.0. Modified from the original. Icons by Lucide, licensed under the ISC License. Portions derived from Feather Icons (MIT). This deck was built with Claude Code (Anthropic) as an authoring tool. All structure, wording, and technical claims were written, verified, and edited by the author. Android™ is a trademark of Google LLC. Kotlin® is a trademark of the Kotlin Foundation. This session is not affiliated with, sponsored, or endorsed by Google LLC, JetBrains s.r.o., or the Kotlin Foundation.
  60. EOF