Skip to content

Commit a40242e

Browse files
committed
fix: biometric prompt is not shown
1 parent ca77566 commit a40242e

File tree

8 files changed

+385
-143
lines changed

8 files changed

+385
-143
lines changed

feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt renamed to feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInput.kt

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package app.k9mail.feature.account.server.settings.ui.common
22

3-
import androidx.biometric.BiometricPrompt
3+
import androidx.activity.compose.LocalActivity
44
import androidx.compose.foundation.layout.PaddingValues
55
import androidx.compose.foundation.layout.fillMaxWidth
66
import androidx.compose.runtime.Composable
@@ -11,30 +11,29 @@ import androidx.compose.runtime.remember
1111
import androidx.compose.runtime.setValue
1212
import androidx.compose.ui.Modifier
1313
import androidx.compose.ui.res.stringResource
14-
import androidx.fragment.app.FragmentActivity
1514
import app.k9mail.core.ui.compose.designsystem.molecule.input.InputLayout
16-
import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput
1715
import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding
1816
import app.k9mail.feature.account.server.settings.R
1917
import kotlinx.coroutines.delay
18+
import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
19+
import net.thunderbird.feature.account.server.settings.ui.common.BiometricAuthenticator
2020
import app.k9mail.core.ui.compose.designsystem.R as RDesign
2121

2222
private const val SHOW_WARNING_DURATION = 5000L
2323

2424
/**
2525
* Variant of [PasswordInput] that only allows the password to be unmasked after the user has authenticated using
26-
* [BiometricPrompt].
27-
*
28-
* Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity].
26+
* [Authenticator] that defaults to [BiometricAuthenticator].
2927
*/
3028
@Composable
31-
fun BiometricPasswordInput(
29+
fun ProtectedPasswordInput(
3230
onPasswordChange: (String) -> Unit,
3331
modifier: Modifier = Modifier,
3432
password: String = "",
3533
isRequired: Boolean = false,
3634
errorMessage: String? = null,
3735
contentPadding: PaddingValues = inputContentPadding(),
36+
authenticator: Authenticator? = null,
3837
) {
3938
var biometricWarning by remember { mutableStateOf<String?>(value = null) }
4039

@@ -56,13 +55,21 @@ fun BiometricPasswordInput(
5655
val needScreenLockMessage =
5756
stringResource(R.string.account_server_settings_password_authentication_screen_lock_required)
5857

59-
TextFieldOutlinedPasswordBiometric(
58+
val resolvedAuthenticator: Authenticator = authenticator ?: run {
59+
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
60+
BiometricAuthenticator(
61+
activity = activity,
62+
title = title,
63+
subtitle = subtitle,
64+
needScreenLockMessage = needScreenLockMessage,
65+
)
66+
}
67+
68+
ProtectedTextFieldOutlinedPassword(
6069
value = password,
6170
onValueChange = onPasswordChange,
62-
authenticationTitle = title,
63-
authenticationSubtitle = subtitle,
64-
needScreenLockMessage = needScreenLockMessage,
6571
onWarningChange = { biometricWarning = it?.toString() },
72+
authenticator = resolvedAuthenticator,
6673
label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label),
6774
isRequired = isRequired,
6875
hasError = errorMessage != null,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package app.k9mail.feature.account.server.settings.ui.common
2+
3+
import android.app.Activity
4+
import android.view.WindowManager
5+
import androidx.activity.compose.LocalActivity
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.runtime.DisposableEffect
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.runtime.rememberCoroutineScope
11+
import androidx.compose.runtime.saveable.rememberSaveable
12+
import androidx.compose.runtime.setValue
13+
import androidx.compose.ui.Modifier
14+
import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword
15+
import kotlinx.coroutines.launch
16+
import net.thunderbird.core.outcome.Outcome
17+
import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
18+
19+
/**
20+
* Variant of [TextFieldOutlinedPassword] that only allows the
21+
* password to be unmasked after the user has authenticated using [Authenticator].
22+
*/
23+
@Suppress("LongParameterList")
24+
@Composable
25+
fun ProtectedTextFieldOutlinedPassword(
26+
value: String,
27+
onValueChange: (String) -> Unit,
28+
onWarningChange: (CharSequence?) -> Unit,
29+
authenticator: Authenticator,
30+
modifier: Modifier = Modifier,
31+
label: String? = null,
32+
isEnabled: Boolean = true,
33+
isReadOnly: Boolean = false,
34+
isRequired: Boolean = false,
35+
hasError: Boolean = false,
36+
) {
37+
var isPasswordVisible by rememberSaveable { mutableStateOf(false) }
38+
var isAuthenticated by rememberSaveable { mutableStateOf(false) }
39+
40+
val activity = LocalActivity.current as Activity
41+
val scope = rememberCoroutineScope()
42+
43+
TextFieldOutlinedPassword(
44+
value = value,
45+
onValueChange = onValueChange,
46+
modifier = modifier,
47+
label = label,
48+
isEnabled = isEnabled,
49+
isReadOnly = isReadOnly,
50+
isRequired = isRequired,
51+
hasError = hasError,
52+
isPasswordVisible = isPasswordVisible,
53+
onPasswordVisibilityToggleClicked = {
54+
if (isAuthenticated) {
55+
isPasswordVisible = !isPasswordVisible
56+
activity.setSecure(isPasswordVisible)
57+
} else {
58+
scope.launch {
59+
when (val outcome = authenticator.authenticate()) {
60+
is Outcome.Success -> {
61+
isAuthenticated = true
62+
isPasswordVisible = true
63+
onWarningChange(null)
64+
activity.setSecure(true)
65+
}
66+
is Outcome.Failure -> {
67+
onWarningChange(outcome.error)
68+
}
69+
}
70+
}
71+
}
72+
},
73+
)
74+
75+
DisposableEffect(key1 = "secureWindow") {
76+
activity.setSecure(isPasswordVisible)
77+
78+
onDispose {
79+
activity.setSecure(false)
80+
}
81+
}
82+
}
83+
84+
private fun Activity.setSecure(secure: Boolean) {
85+
window.setFlags(if (secure) WindowManager.LayoutParams.FLAG_SECURE else 0, WindowManager.LayoutParams.FLAG_SECURE)
86+
}

feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ fun ServerSettingsPasswordInput(
2727
contentPadding = contentPadding,
2828
)
2929
} else {
30-
BiometricPasswordInput(
30+
ProtectedPasswordInput(
3131
onPasswordChange = onPasswordChange,
3232
modifier = modifier,
3333
password = password,

feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt

Lines changed: 0 additions & 131 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package net.thunderbird.feature.account.server.settings.ui.common
2+
3+
import net.thunderbird.core.outcome.Outcome
4+
5+
/**
6+
* A functional interface for authenticating a user.
7+
*/
8+
fun interface Authenticator {
9+
suspend fun authenticate(): Outcome<Unit, String>
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package net.thunderbird.feature.account.server.settings.ui.common
2+
3+
import androidx.biometric.BiometricManager
4+
import androidx.biometric.BiometricPrompt
5+
import androidx.core.content.ContextCompat
6+
import androidx.fragment.app.FragmentActivity
7+
import kotlin.coroutines.resume
8+
import kotlinx.coroutines.suspendCancellableCoroutine
9+
import net.thunderbird.core.outcome.Outcome
10+
11+
/**
12+
* An [Authenticator] implementation that uses Android's BiometricPrompt to authenticate the user.
13+
*
14+
* Note: Due to limitations of [androidx.biometric.BiometricPrompt] this composable can only be used inside a
15+
* [androidx.fragment.app.FragmentActivity].
16+
*
17+
* @param activity The FragmentActivity context to use for the BiometricPrompt.
18+
* @param title The title to display on the biometric prompt.
19+
* @param subtitle The subtitle to display on the biometric prompt.
20+
* @param needScreenLockMessage The message to display when screen lock is required but not set
21+
*/
22+
class BiometricAuthenticator(
23+
private val activity: FragmentActivity,
24+
private val title: String,
25+
private val subtitle: String,
26+
private val needScreenLockMessage: String,
27+
) : Authenticator {
28+
29+
@Suppress("TooGenericExceptionCaught")
30+
override suspend fun authenticate(): Outcome<Unit, String> = suspendCancellableCoroutine { continuation ->
31+
val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
32+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
33+
if (continuation.isActive) continuation.resume(Outcome.Success(Unit))
34+
}
35+
36+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
37+
val message: String = when (errorCode) {
38+
BiometricPrompt.ERROR_HW_NOT_PRESENT,
39+
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
40+
BiometricPrompt.ERROR_NO_BIOMETRICS,
41+
-> needScreenLockMessage
42+
43+
else -> if (errString.isNotEmpty()) errString.toString() else "Authentication failed"
44+
}
45+
if (continuation.isActive) continuation.resume(Outcome.Failure(message))
46+
}
47+
}
48+
49+
val promptInfo = BiometricPrompt.PromptInfo.Builder()
50+
.setAllowedAuthenticators(
51+
BiometricManager.Authenticators.BIOMETRIC_STRONG or
52+
BiometricManager.Authenticators.BIOMETRIC_WEAK or
53+
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
54+
)
55+
.setTitle(title)
56+
.setSubtitle(subtitle)
57+
.build()
58+
59+
val executor = ContextCompat.getMainExecutor(activity)
60+
try {
61+
BiometricPrompt(activity, executor, authenticationCallback).authenticate(promptInfo)
62+
} catch (e: Exception) {
63+
val message: String = e.message ?: "Unable to start biometric prompt"
64+
if (continuation.isActive) continuation.resume(Outcome.Failure(message))
65+
}
66+
67+
continuation.invokeOnCancellation {
68+
// No explicit cancellation support for BiometricPrompt
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)