Skip to content

Commit f44dad5

Browse files
committed
refactor: add localization and rememberBiometricAuthenticator
1 parent 5a2c390 commit f44dad5

File tree

7 files changed

+135
-70
lines changed

7 files changed

+135
-70
lines changed
Lines changed: 28 additions & 26 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.activity.compose.LocalActivity
3+
import androidx.annotation.StringRes
44
import androidx.compose.foundation.layout.PaddingValues
55
import androidx.compose.foundation.layout.fillMaxWidth
66
import androidx.compose.runtime.Composable
@@ -15,15 +15,16 @@ import app.k9mail.core.ui.compose.designsystem.molecule.input.InputLayout
1515
import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding
1616
import app.k9mail.feature.account.server.settings.R
1717
import kotlinx.coroutines.delay
18+
import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError
1819
import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
19-
import net.thunderbird.feature.account.server.settings.ui.common.BiometricAuthenticator
20+
import net.thunderbird.feature.account.server.settings.ui.common.rememberBiometricAuthenticator
2021
import app.k9mail.core.ui.compose.designsystem.R as RDesign
2122

2223
private const val SHOW_WARNING_DURATION = 5000L
2324

2425
/**
2526
* Variant of [PasswordInput] that only allows the password to be unmasked after the user has authenticated using
26-
* [Authenticator] that defaults to [BiometricAuthenticator].
27+
* [Authenticator] that defaults to [rememberBiometricAuthenticator].
2728
*/
2829
@Composable
2930
fun ProtectedPasswordInput(
@@ -33,47 +34,48 @@ fun ProtectedPasswordInput(
3334
isRequired: Boolean = false,
3435
errorMessage: String? = null,
3536
contentPadding: PaddingValues = inputContentPadding(),
36-
authenticator: Authenticator? = null,
37+
authenticator: Authenticator = rememberBiometricAuthenticator(
38+
title = stringResource(R.string.account_server_settings_password_authentication_title),
39+
subtitle = stringResource(R.string.account_server_settings_password_authentication_subtitle),
40+
),
3741
) {
38-
var biometricWarning by remember { mutableStateOf<String?>(value = null) }
42+
var authenticationError by remember { mutableStateOf<AuthenticationError?>(value = null) }
43+
val authenticationWarning = authenticationError?.let { stringResource(it.mapToStringRes()) }
3944

40-
LaunchedEffect(key1 = biometricWarning) {
41-
if (biometricWarning != null) {
45+
LaunchedEffect(key1 = authenticationError) {
46+
if (authenticationError != null) {
4247
delay(SHOW_WARNING_DURATION)
43-
biometricWarning = null
48+
authenticationError = null
4449
}
4550
}
4651

4752
InputLayout(
4853
modifier = modifier,
4954
contentPadding = contentPadding,
5055
errorMessage = errorMessage,
51-
warningMessage = biometricWarning,
56+
warningMessage = authenticationWarning,
5257
) {
53-
val title = stringResource(R.string.account_server_settings_password_authentication_title)
54-
val subtitle = stringResource(R.string.account_server_settings_password_authentication_subtitle)
55-
val needScreenLockMessage =
56-
stringResource(R.string.account_server_settings_password_authentication_screen_lock_required)
57-
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-
6858
ProtectedTextFieldOutlinedPassword(
6959
value = password,
7060
onValueChange = onPasswordChange,
71-
onWarningChange = { biometricWarning = it?.toString() },
72-
authenticator = resolvedAuthenticator,
61+
onWarningChange = { authenticationError = it },
62+
authenticator = authenticator,
7363
label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label),
7464
isRequired = isRequired,
7565
hasError = errorMessage != null,
7666
modifier = Modifier.fillMaxWidth(),
7767
)
7868
}
7969
}
70+
71+
@StringRes
72+
private fun AuthenticationError.mapToStringRes(): Int {
73+
return when (this) {
74+
AuthenticationError.NotAvailable ->
75+
R.string.account_server_settings_password_authentication_screen_lock_required
76+
AuthenticationError.Failed ->
77+
R.string.account_server_settings_password_authentication_failed
78+
AuthenticationError.UnableToStart ->
79+
R.string.account_server_settings_password_authentication_unable_to_start
80+
}
81+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
1414
import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword
1515
import kotlinx.coroutines.launch
1616
import net.thunderbird.core.outcome.Outcome
17+
import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError
1718
import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
1819

1920
/**
@@ -25,7 +26,7 @@ import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
2526
fun ProtectedTextFieldOutlinedPassword(
2627
value: String,
2728
onValueChange: (String) -> Unit,
28-
onWarningChange: (CharSequence?) -> Unit,
29+
onWarningChange: (AuthenticationError?) -> Unit,
2930
authenticator: Authenticator,
3031
modifier: Modifier = Modifier,
3132
label: String? = null,

feature/account/server/settings/src/main/kotlin/net/thunderbird/feature/account/server/settings/ui/common/Authenticator.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,31 @@ import net.thunderbird.core.outcome.Outcome
66
* A functional interface for authenticating a user.
77
*/
88
fun interface Authenticator {
9-
suspend fun authenticate(): Outcome<Unit, String>
9+
10+
/**
11+
* Authenticates the user.
12+
*
13+
* @return An [Outcome] representing the result of the authentication process.
14+
*/
15+
suspend fun authenticate(): Outcome<Unit, AuthenticationError>
16+
}
17+
18+
/**
19+
* Authentication errors that can occur during the authentication process.
20+
*/
21+
sealed interface AuthenticationError {
22+
/**
23+
* The user has not set up any authentication methods (e.g. screen lock, biometrics).
24+
*/
25+
data object NotAvailable : AuthenticationError
26+
27+
/**
28+
* The authentication failed.
29+
*/
30+
data object Failed : AuthenticationError
31+
32+
/**
33+
* An unknown error occurred, and authentication could not be started.
34+
*/
35+
data object UnableToStart : AuthenticationError
1036
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package net.thunderbird.feature.account.server.settings.ui.common
22

3+
import androidx.activity.compose.LocalActivity
34
import androidx.biometric.BiometricManager
45
import androidx.biometric.BiometricPrompt
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.runtime.remember
58
import androidx.core.content.ContextCompat
69
import androidx.fragment.app.FragmentActivity
710
import kotlin.coroutines.resume
811
import kotlinx.coroutines.suspendCancellableCoroutine
12+
import net.thunderbird.core.logging.legacy.Log
913
import net.thunderbird.core.outcome.Outcome
1014

1115
/**
@@ -17,55 +21,81 @@ import net.thunderbird.core.outcome.Outcome
1721
* @param activity The FragmentActivity context to use for the BiometricPrompt.
1822
* @param title The title to display on the biometric prompt.
1923
* @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
2124
*/
2225
class BiometricAuthenticator(
2326
private val activity: FragmentActivity,
2427
private val title: String,
2528
private val subtitle: String,
26-
private val needScreenLockMessage: String,
2729
) : Authenticator {
2830

2931
@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-
}
32+
override suspend fun authenticate(): Outcome<Unit, AuthenticationError> =
33+
suspendCancellableCoroutine { continuation ->
34+
val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
35+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
36+
if (continuation.isActive) continuation.resume(Outcome.Success(Unit))
37+
}
3538

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
39+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
40+
val error: AuthenticationError = when (errorCode) {
41+
BiometricPrompt.ERROR_HW_NOT_PRESENT,
42+
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
43+
BiometricPrompt.ERROR_NO_BIOMETRICS,
44+
-> AuthenticationError.NotAvailable
4245

43-
else -> if (errString.isNotEmpty()) errString.toString() else "Authentication failed"
46+
else -> AuthenticationError.Failed
47+
}
48+
if (continuation.isActive) continuation.resume(Outcome.Failure(error))
4449
}
45-
if (continuation.isActive) continuation.resume(Outcome.Failure(message))
4650
}
47-
}
4851

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()
52+
val promptInfo = BiometricPrompt.PromptInfo.Builder()
53+
.setAllowedAuthenticators(
54+
BiometricManager.Authenticators.BIOMETRIC_STRONG or
55+
BiometricManager.Authenticators.BIOMETRIC_WEAK or
56+
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
57+
)
58+
.setTitle(title)
59+
.setSubtitle(subtitle)
60+
.build()
5861

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))
62+
val executor = ContextCompat.getMainExecutor(activity)
63+
try {
64+
BiometricPrompt(activity, executor, authenticationCallback).authenticate(promptInfo)
65+
} catch (e: Exception) {
66+
Log.e("BiometricAuthenticator", "Failed to start biometric authentication", e)
67+
if (continuation.isActive) continuation.resume(Outcome.Failure(AuthenticationError.UnableToStart))
68+
}
69+
70+
continuation.invokeOnCancellation {
71+
// No explicit cancellation support for BiometricPrompt
72+
}
6573
}
74+
}
6675

67-
continuation.invokeOnCancellation {
68-
// No explicit cancellation support for BiometricPrompt
76+
/**
77+
* Creates and remembers a [BiometricAuthenticator].
78+
*
79+
* @param title The title to display on the biometric prompt.
80+
* @param subtitle The subtitle to display on the biometric prompt.
81+
*/
82+
@Composable
83+
fun rememberBiometricAuthenticator(
84+
title: String,
85+
subtitle: String,
86+
): Authenticator {
87+
val activity = LocalActivity.current
88+
return remember(activity, title, subtitle) {
89+
val fragmentActivity = activity as? FragmentActivity
90+
if (fragmentActivity != null) {
91+
BiometricAuthenticator(
92+
activity = fragmentActivity,
93+
title = title,
94+
subtitle = subtitle,
95+
)
96+
} else {
97+
// Fallback for previews and other non-FragmentActivity contexts
98+
Authenticator { Outcome.Failure(AuthenticationError.UnableToStart) }
6999
}
70100
}
71101
}

feature/account/server/settings/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,6 @@
4141
<string name="account_server_settings_password_authentication_subtitle">Unlock to view your password</string>
4242
<!-- Please use the same translation for "screen lock" as is used in the 'Security' section in Android's settings app -->
4343
<string name="account_server_settings_password_authentication_screen_lock_required">To view your password here, enable screen lock on this device.</string>
44+
<string name="account_server_settings_password_authentication_failed">Authentication failed!</string>
45+
<string name="account_server_settings_password_authentication_unable_to_start">Unable to start biometric prompt.</string>
4446
</resources>

feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedPasswordInputKtTest.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import androidx.compose.ui.test.onNodeWithText
77
import androidx.compose.ui.test.performClick
88
import app.k9mail.core.ui.compose.testing.ComposeTest
99
import app.k9mail.core.ui.compose.testing.setContentWithTheme
10+
import app.k9mail.feature.account.server.settings.R
1011
import net.thunderbird.core.outcome.Outcome
12+
import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError
1113
import net.thunderbird.feature.account.server.settings.ui.common.Authenticator
1214
import org.junit.Test
1315
import app.k9mail.core.ui.compose.designsystem.R as RDesign
@@ -21,7 +23,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() {
2123
ProtectedPasswordInput(
2224
password = "",
2325
onPasswordChange = {},
24-
authenticator = Authenticator { Outcome.Failure("irrelevant") },
26+
authenticator = Authenticator { Outcome.Failure(AuthenticationError.Failed) },
2527
)
2628
}
2729

@@ -34,8 +36,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() {
3436
fun `should show warning message when authenticator fails`() = runComposeTest {
3537
// Arrange
3638
val password = "Password input"
37-
val errorMessage = "Auth failed"
38-
val failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(errorMessage) }
39+
val failingAuthenticator: Authenticator = Authenticator { Outcome.Failure(AuthenticationError.NotAvailable) }
3940
setContentWithTheme {
4041
ProtectedPasswordInput(
4142
password = password,
@@ -50,7 +51,9 @@ class ProtectedPasswordInputKtTest : ComposeTest() {
5051
).performClick()
5152

5253
// Assert
53-
onNodeWithText(errorMessage).assertIsDisplayed()
54+
onNodeWithText(
55+
getString(R.string.account_server_settings_password_authentication_screen_lock_required),
56+
).assertIsDisplayed()
5457
}
5558

5659
@Test
@@ -62,7 +65,7 @@ class ProtectedPasswordInputKtTest : ComposeTest() {
6265
password = "",
6366
onPasswordChange = {},
6467
errorMessage = errorMessage,
65-
authenticator = Authenticator { Outcome.Failure("irrelevant") },
68+
authenticator = Authenticator { Outcome.Failure(AuthenticationError.Failed) },
6669
)
6770
}
6871

feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/common/ProtectedTextFieldOutlinedPasswordKtTest.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.compose.ui.test.performClick
88
import app.k9mail.core.ui.compose.testing.ComposeTest
99
import app.k9mail.core.ui.compose.testing.setContentWithTheme
1010
import net.thunderbird.core.outcome.Outcome
11+
import net.thunderbird.feature.account.server.settings.ui.common.AuthenticationError
1112
import org.junit.Test
1213
import app.k9mail.core.ui.compose.designsystem.R as RDesign
1314

@@ -21,8 +22,8 @@ class ProtectedTextFieldOutlinedPasswordKtTest : ComposeTest() {
2122
ProtectedTextFieldOutlinedPassword(
2223
value = value,
2324
onValueChange = { value = it },
24-
onWarningChange = {},
25-
authenticator = { Outcome.Failure("Auth required") },
25+
onWarningChange = { _ -> },
26+
authenticator = { Outcome.Failure(AuthenticationError.Failed) },
2627
)
2728
}
2829

@@ -49,8 +50,8 @@ class ProtectedTextFieldOutlinedPasswordKtTest : ComposeTest() {
4950
ProtectedTextFieldOutlinedPassword(
5051
value = value,
5152
onValueChange = { value = it },
52-
onWarningChange = {},
53-
authenticator = { Outcome.Failure("Auth required") },
53+
onWarningChange = { _ -> },
54+
authenticator = { Outcome.Failure(AuthenticationError.Failed) },
5455
)
5556
}
5657

0 commit comments

Comments
 (0)