android-biometric-login
Required Plugins
Superpowers plugin: MUST be active for all work using this skill. Use throughout the entire build pipeline — design decisions, code generation, debugging, quality checks, and any task where it offers enhanced capabilities. If superpowers provides a better way to accomplish something, prefer it over the default approach.
Android Biometric Login
Add optional fingerprint/face authentication as a gate on app launch. Uses the AndroidX Biometric library (BIOMETRIC_STRONG — Class 3 biometrics only). The feature is opt-in: users enable it in Settings, and it triggers on every app launch from the splash screen.
Overview
Flow: App launch → Splash → Check biometric pref → Show system biometric prompt → Success (Dashboard) or Failure (Login screen).
Key principles:
- Optional, not mandatory — users choose to enable via a Settings toggle
- Verify before enabling — require biometric auth to turn the feature ON (prevents unauthorized enabling)
- Graceful degradation — if device has no biometric hardware, hide the toggle entirely
- Survive logout — biometric preference persists across logout/re-login
- No custom UI — use the system
BiometricPromptdialog (consistent UX, handles retries)
Dependencies
# libs.versions.toml
[versions]
biometric = "1.1.0"
[libraries]
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
// build.gradle.kts
implementation(libs.biometric)
No manifest permissions needed — BiometricPrompt API on Android 10+ (minSdk 29) handles everything.
Architecture
core/auth/
BiometricHelper.kt — Static utility: canAuthenticate() + authenticate()
AuthManager.kt — Stores biometric preference in EncryptedSharedPreferences
feature/splash/ui/
SplashScreen.kt — Checks pref + triggers prompt on app launch
feature/settings/ui/
SettingsScreen.kt — Toggle switch with verify-before-enable
SettingsViewModel.kt — Delegates to AuthManager
Step 1: BiometricHelper Utility
A stateless object with two functions. No DI needed — takes FragmentActivity as parameter.
package com.example.app.core.auth
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
object BiometricHelper {
/**
* Returns true if device has enrolled Class 3 biometrics (fingerprint or face).
*/
fun canAuthenticate(activity: FragmentActivity): Boolean {
val bm = BiometricManager.from(activity)
return bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Shows the system biometric prompt. Calls onSuccess or onFailure on completion.
* onAuthenticationFailed() is intentionally not overridden — the system dialog
* shows its own retry UI (e.g., "Try again" for bad fingerprint).
*/
fun authenticate(
activity: FragmentActivity,
title: String,
subtitle: String,
negativeButtonText: String,
onSuccess: () -> Unit,
onFailure: () -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Cancel pressed, lockout, or no biometrics enrolled
onFailure()
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText(negativeButtonText)
.build()
prompt.authenticate(promptInfo)
}
}
Key Decisions
BIOMETRIC_STRONGonly — noDEVICE_CREDENTIALfallback (PIN/pattern). The negative button sends user to the login screen instead.onAuthenticationFailednot overridden — the system UI handles retry. OnlyonAuthenticationError(cancel/lockout) triggersonFailure.FragmentActivityrequired —BiometricPromptneeds aFragmentActivity. Compose activities extendComponentActivity, which extendsFragmentActivity, so this works out of the box.
Step 2: AuthManager Storage
Store the biometric preference in EncryptedSharedPreferences alongside auth tokens. The key insight: preserve biometric preference on logout. IMPORTANT: The EncryptedSharedPreferences init MUST be wrapped in try-catch with fallback to regular SharedPreferences — Samsung Knox throws KeyStoreException during MasterKey creation (see android-development security skill).
// In AuthManager.kt
companion object {
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
}
fun isBiometricEnabled(): Boolean =
securePreferences.getBoolean(KEY_BIOMETRIC_ENABLED)
fun setBiometricEnabled(enabled: Boolean) {
securePreferences.putBoolean(KEY_BIOMETRIC_ENABLED, enabled)
}
fun clearAuth() {
// Save preferences that survive logout
val savedBiometric = isBiometricEnabled()
val savedLanguage = getLanguage()
securePreferences.clear()
// Restore
if (savedBiometric) securePreferences.putBoolean(KEY_BIOMETRIC_ENABLED, true)
if (savedLanguage != null) securePreferences.putString(KEY_LANGUAGE, savedLanguage)
}
Step 3: Splash Screen Integration
The splash screen is the single integration point. Uses CompletableDeferred to bridge the callback-based BiometricPrompt into coroutine-based LaunchedEffect.
@Composable
fun SplashScreen(
authManager: AuthManager,
onNavigateToLogin: () -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToChangePassword: () -> Unit
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
// Pre-resolve string resources outside the coroutine
val biometricTitle = stringResource(R.string.biometric_prompt_title)
val biometricSubtitle = stringResource(R.string.biometric_prompt_subtitle)
val biometricCancel = stringResource(R.string.biometric_prompt_cancel)
LaunchedEffect(Unit) {
delay(1500) // Splash display time
if (!authManager.isLoggedIn()) {
onNavigateToLogin()
return@LaunchedEffect
}
val biometricPref = authManager.isBiometricEnabled()
val canAuth = activity != null && BiometricHelper.canAuthenticate(activity)
if (biometricPref && canAuth) {
// Bridge callback → coroutine
val result = CompletableDeferred<Boolean>()
BiometricHelper.authenticate(
activity = activity!!,
title = biometricTitle,
subtitle = biometricSubtitle,
negativeButtonText = biometricCancel,
onSuccess = { result.complete(true) },
onFailure = { result.complete(false) }
)
if (result.await()) {
// Biometric passed — check force password change then go to main
if (authManager.isForcePasswordChange()) {
onNavigateToChangePassword()
} else {
onNavigateToMain()
}
} else {
// Biometric failed/cancelled — send to login
onNavigateToLogin()
}
} else {
// No biometric — go straight through
if (authManager.isForcePasswordChange()) {
onNavigateToChangePassword()
} else {
onNavigateToMain()
}
}
}
// Splash UI (logo, brand name, etc.)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Image(
painter = painterResource(R.mipmap.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(150.dp)
)
}
}
Navigation Setup
The splash screen pops itself from the back stack when navigating:
composable(Screen.Splash.route) {
SplashScreen(
authManager = authManager,
onNavigateToLogin = {
navController.navigate(Screen.Login.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
},
onNavigateToMain = {
navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
},
onNavigateToChangePassword = {
navController.navigate(Screen.ChangePassword.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
}
)
}
Step 4: Settings Toggle
The toggle is conditionally rendered — only shown if the device supports biometrics. Enabling requires biometric verification first.
// In SettingsScreen.kt, inside the Security section
val activity = context as? FragmentActivity
if (activity != null && BiometricHelper.canAuthenticate(activity)) {
HorizontalDivider()
var biometricEnabled by remember { mutableStateOf(viewModel.isBiometricEnabled()) }
val verifyTitle = stringResource(R.string.biometric_verify_title)
val verifySubtitle = stringResource(R.string.biometric_verify_subtitle)
val verifyCancel = stringResource(R.string.biometric_prompt_cancel)
ListItem(
headlineContent = { Text(stringResource(R.string.biometric_login_title)) },
supportingContent = { Text(stringResource(R.string.biometric_login_subtitle)) },
leadingContent = {
Icon(Icons.Default.Fingerprint, contentDescription = null,
tint = MaterialTheme.colorScheme.primary)
},
trailingContent = {
Switch(
checked = biometricEnabled,
onCheckedChange = { enabled ->
if (enabled) {
// Verify identity before enabling
BiometricHelper.authenticate(
activity = activity,
title = verifyTitle,
subtitle = verifySubtitle,
negativeButtonText = verifyCancel,
onSuccess = {
viewModel.setBiometricEnabled(true)
biometricEnabled = true
},
onFailure = { /* Verification failed — don't enable */ }
)
} else {
// Disabling doesn't require verification
viewModel.setBiometricEnabled(false)
biometricEnabled = false
}
}
)
}
)
}
Step 5: String Resources
<!-- Biometric (7 strings, translate all) -->
<string name="biometric_login_title">Biometric Login</string>
<string name="biometric_login_subtitle">Use fingerprint or face to unlock</string>
<string name="biometric_prompt_title">Biometric Login</string>
<string name="biometric_prompt_subtitle">Verify your identity to access the app</string>
<string name="biometric_prompt_cancel">Use Password</string>
<string name="biometric_verify_title">Verify Identity</string>
<string name="biometric_verify_subtitle">Authenticate to enable biometric login</string>
Flow Diagram
App Launch
↓
[Splash Screen] — 1.5s delay
↓
isLoggedIn()?
├─ NO → Login Screen
└─ YES
↓
isBiometricEnabled() && canAuthenticate()?
├─ YES → System BiometricPrompt
│ ├─ Success → forcePasswordChange? → Dashboard / ChangePassword
│ └─ Failure/Cancel → Login Screen
└─ NO → forcePasswordChange? → Dashboard / ChangePassword
Patterns & Anti-Patterns
DO
- Use
BIOMETRIC_STRONG(Class 3) for security-sensitive apps - Require biometric verification when the user turns the feature ON
- Preserve biometric preference across logout (
clearAuth()saves + restores it) - Use
CompletableDeferredto bridge BiometricPrompt callbacks into coroutines - Hide the toggle entirely on devices without biometric hardware
- Use
context as? FragmentActivitysafely (never force-cast) - Pre-resolve string resources before entering
LaunchedEffect(Compose rule)
DON'T
- Don't add
DEVICE_CREDENTIALas a fallback — it defeats the purpose of biometric gate - Don't override
onAuthenticationFailed()— the system handles retry UI - Don't store biometric data yourself — the system handles enrollment and matching
- Don't show biometric prompt on login screen — only on splash (user is already authenticated)
- Don't require biometric for disabling the feature — that traps users who can't authenticate
- Don't declare manifest permissions —
BiometricPrompton API 29+ doesn't need them - Don't use
KeyguardManageror deprecatedFingerprintManager— useBiometricPromptonly
Edge Cases
| Scenario | Behavior |
|---|---|
| No biometric hardware | Settings toggle hidden, splash skips biometric |
| Biometrics enrolled then removed | canAuthenticate() returns false, splash skips |
| User cancels prompt | onFailure() → navigate to Login |
| Too many failed attempts (lockout) | System shows lockout message, then onFailure() |
| Force password change + biometric | Biometric first, then redirect to ChangePassword |
| App killed during prompt | Next launch starts fresh from splash |
| Multiple accounts on device | Biometric pref is per-app, not per-user |
Integration with Other Skills
android-biometric-login
├── android-development (project structure, Hilt, EncryptedSharedPreferences)
├── dual-auth-rbac (JWT auth, AuthManager, token storage)
└── jetpack-compose-ui (Settings ListItem, Switch, Material 3 theming)
Key integrations:
dual-auth-rbac: BiometricHelper works alongside JWT auth — biometric gates app access, JWT gates API accessandroid-development: Follows MVVM pattern — ViewModel delegates to AuthManager, UI observes statejetpack-compose-ui: Settings toggle uses Material 3ListItem+Switchpattern
Checklist
- Add
androidx.biometric:biometric:1.1.0dependency - Create
BiometricHelperobject withcanAuthenticate()+authenticate() - Add biometric preference to AuthManager (persists across logout)
- Integrate biometric check in Splash screen with
CompletableDeferred - Add Settings toggle with verify-before-enable
- Add 7 string resources (translate to all supported languages)
- Test on device with biometrics, device without, and emulator