Skip to content

Using biometric login in android

Published: at 08:40 PM (5 min read)

Why Biometric login

Biometric credentials are not shared to server, unlike JWt auth token, login and password. It is just enabled on client side, and if client (in our case android OS) says its ok, we would get into app.

Some other benefits includes

Adding dependency

implementation("androidx.biometric:biometric:1.2.0-alpha05")
implementation("androidx.appcompat:appcompat:1.7.0-alpha03")

Code changes

setContent {
			MainScreen(
				onShowOSBiometricsModal = {
					authenticateWithOSBiometricsModal(
						biometricPromptCallback = handleBiometricAuthResult(),
					)
				},
				onContinueWithoutAuthentication = {
					// todo remove
				},
				userManager
			)
		}

Some more code to handle


	override fun onPause() {
		Log.d(TAG, "onPause: called")
		super.onPause()
		userManager.startUserInactiveTimeCounter()
	}

	private fun handleBiometricAuthResult(
		onAuthSuccess: () -> Unit = {}
	): BiometricPrompt.AuthenticationCallback {
		return object : BiometricPrompt.AuthenticationCallback() {
			override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
				userManager.markScreenAsUnlocked()
				onAuthSuccess()
			}

			override fun onAuthenticationFailed() {}
			override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {}
		}
	}

	private fun authenticateWithOSBiometricsModal(
		biometricPromptCallback: BiometricPrompt.AuthenticationCallback
	) {
		val executor = ContextCompat.getMainExecutor(this)
		val biometricPrompt = BiometricPrompt(
			this,
			executor,
			biometricPromptCallback
		)

		val promptInfo = BiometricPrompt.PromptInfo.Builder()
			.setTitle(
				getString(R.string.authentication_required)
			)
			.setSubtitle(
				getString(R.string.authentication_required_description)
			)
			.setAllowedAuthenticators(
				BiometricManager.Authenticators.BIOMETRIC_WEAK or
					BiometricManager.Authenticators.DEVICE_CREDENTIAL
			)
			.setConfirmationRequired(false)
			.build()

		biometricPrompt.authenticate(promptInfo)
	}

Some more code which does following


@Composable
fun MainScreen(
	onShowOSBiometricsModal: () -> Unit,
	onContinueWithoutAuthentication: () -> Unit,
	userManager: UserManager
) {
	LaunchedEffect(key1 = Unit, block = {
		FirebaseMessagingTokenLogger().apply {
			logToken()
		}
	})
	val currentUser by remember {
		mutableStateOf(userManager.getCurrentUser())
	}
	val isLoggedIn by remember(key1 = currentUser) {
		mutableStateOf(currentUser != null)
	}
	val isAppLocked by userManager.isAppLocked.collectAsStateWithLifecycle()
	when (isAppLocked) {
		true -> {
			UniverseTheme {
				FingerPrint(
					onShowOSBiometricsModal = onShowOSBiometricsModal,
					onContinueWithoutAuthentication = onContinueWithoutAuthentication,
				)
			}
		}

		false -> {
			UniverseTheme {
				DestinationsNavHost(
					navGraph = when (isLoggedIn) {
						true -> {
							NavGraphs.homeGraph
						}

						false -> {
							NavGraphs.root
						}
	@@ -44,3 +165,117 @@ class MainActivity : ComponentActivity() {
		}
	}
}

@Composable
fun FingerPrint(
	onShowOSBiometricsModal: () -> Unit,
	onContinueWithoutAuthentication: () -> Unit,
) {
	val latestOnContinueWithoutAuthentication by rememberUpdatedState(
		newValue = onContinueWithoutAuthentication
	)
	val latestOnShowOSBiometricsModal by rememberUpdatedState(onShowOSBiometricsModal)

	val context = LocalContext.current
	LaunchedEffect(key1 = Unit, block = {
		osAuthentication(
			context = context,
			onShowOSBiometricsModal = latestOnShowOSBiometricsModal,
			onContinueWithoutAuthentication = latestOnContinueWithoutAuthentication
		)
	})
	Column(
		modifier = Modifier
			.fillMaxSize()
			.background(
				color = UniverseTheme.colors.primary,
			),
		horizontalAlignment = Alignment.CenterHorizontally,
	) {
		Text(
			text = buildAnnotatedString {
				withStyle(
					style = SpanStyle(
						fontSize = 80.sp,
						color = UniverseTheme.colors.onSecondary,
					),
				) {
					append("NFSW")
				}
				withStyle(
					style = SpanStyle(
						fontSize = 20.sp,
						color = UniverseTheme.colors.onSecondary,
					),
				) {
					append("content")
				}
			},
			modifier = Modifier
				.padding(
					vertical = 10.dp,
				),
			textAlign = TextAlign.Center,
			style = UniverseTheme.typography.headlineLarge,
		)
		Text(
			text = "App is locked",
			modifier = Modifier
				.fillMaxWidth()
				.padding(
					vertical = 10.dp,
				),
			color = UniverseTheme.colors.onSecondary,
			textAlign = TextAlign.Center,
			style = UniverseTheme.typography.headlineMedium,
		)

		Image(
			modifier = Modifier
				.clickable {
					osAuthentication(
						context = context,
						onShowOSBiometricsModal = latestOnShowOSBiometricsModal,
						onContinueWithoutAuthentication = latestOnContinueWithoutAuthentication
					)
				}
				.size(width = 96.dp, height = 138.dp),
			painter = painterResource(id = R.drawable.ic_fingerprint),
			contentScale = ContentScale.FillBounds,
			contentDescription = "unlock icon",
			colorFilter = ColorFilter.tint(
				color = UniverseTheme.colors.onPrimary,
			),
		)
	}
}

private fun osAuthentication(
	context: Context,
	onShowOSBiometricsModal: () -> Unit,
	onContinueWithoutAuthentication: () -> Unit
) {
	if (hasLockScreen(context)) {
		onShowOSBiometricsModal()
	} else {
		onContinueWithoutAuthentication()
	}
}

@Preview("default")
@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview("large font", fontScale = 2f)
@Composable
fun PreviewFingerPrint() {
	UniverseTheme {
		FingerPrint(
			onContinueWithoutAuthentication = {},
			onShowOSBiometricsModal = {},
		)
	}
}

fun hasLockScreen(context: Context): Boolean {
	val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
	return keyguardManager.isDeviceSecure
}

Since you have migrated from ComposeActivity to Androidx activity theming also need to be changed from <style name="AppTheme" parent="android:Theme.Material.NoActionBar"/> to <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"/>

In your user-manager repository u can have something like this


class UserManager(
	private val realmRepository: RealmRepository,
) {

	// Time in seconds
	private val userTimeInActivityTime = 20
	private val _isAppLocked = MutableStateFlow(true)
	val isAppLocked: StateFlow<Boolean>
		get() = _isAppLocked

	private val scope = CoroutineScope(Dispatchers.Default)
	private var userInactiveTime = 0
	private var userInactiveJob: Job? = null

	fun startUserInactiveTimeCounter() {
		if (userInactiveJob != null && userInactiveJob!!.isActive) return

		userInactiveJob = scope.launch {
			while (userInactiveTime < userTimeInActivityTime &&
				userInactiveJob != null && !userInactiveJob?.isCancelled!!
			) {
				delay(1000)
				userInactiveTime += 1
			}

			if (!isAppLocked.value) {
				_isAppLocked.value = true
			}
			cancel()
		}
	}

	fun markScreenAsUnlocked() {
		_isAppLocked.value = false
	}
}

In multi activity architecture, where u have multiple activity, instead of starting startUserInactiveTimeCounter on onStop of mainActivity, you can listen to application process lifecycle change in app class, and start reacting on it. That would work like a charm.