Skip to content

Commit babf494

Browse files
committed
refactor: enhance biometric authentication logic and platform implementations
- Refactor `BiometricInteractor` on iOS to improve type safety with explicit `CFTypeRef` and `OSStatus` handling, and simplify `memScoped` logic. - Update `BiometricInteractor` on Android to utilize `SharedPreferences.edit` KTX extension, add detailed logging for Keystore failures, and refactor cipher initialization. - Optimize `BiometricEnrollViewModel` state updates to use single `update` blocks and named arguments for better readability. - Simplify `SignInScreen` and `BiometricEnrollDialog` by resolving string resources directly within the UI components and streamlining action passing. - Switch iOS project configuration from manual to automatic code signing and remove platform-specific development team overrides. - Refactor Android Koin dependency injection to use `singleOf` for `BiometricInteractor`. - Add documentation/TODOs regarding the removal of Compose-specific `AutofillManager` dependencies from presentation ViewModels. - Clean up unused methods, such as `BiometricEnrollResult.hideError()`, and improve formatting across the presentation and UI modules. - Update iOS user interface state and workspace configuration.
1 parent facccbf commit babf494

14 files changed

Lines changed: 163 additions & 162 deletions

File tree

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,8 @@ class SignInViewModelTest {
138138
@Test
139139
fun biometricSignInSuccess() = runTest {
140140
val pass = StubEditable("pass")
141-
Mockito.`when`(
142-
mockBiometricInteractor.decryptStoredPassword(
143-
anyObject(), anyObject(), anyObject()
144-
)
145-
).thenReturn(DecryptedPasswordResult.Success(pass))
141+
Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject()))
142+
.thenReturn(DecryptedPasswordResult.Success(pass))
146143
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
147144
signInViewModel.stateFlow.test {
148145
assertEquals(SignInResult.ShowSignInForm, awaitItem())
@@ -154,11 +151,8 @@ class SignInViewModelTest {
154151

155152
@Test
156153
fun biometricSignInUnavailableClearsState() = runTest {
157-
Mockito.`when`(
158-
mockBiometricInteractor.decryptStoredPassword(
159-
anyObject(), anyObject(), anyObject()
160-
)
161-
).thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable))
154+
Mockito.`when`(mockBiometricInteractor.decryptStoredPassword(anyObject(), anyObject(), anyObject()))
155+
.thenReturn(DecryptedPasswordResult.Failure(BiometricResult.Unavailable))
162156
signInViewModel.biometricVisibleFlow.test {
163157
assertFalse(awaitItem())
164158
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))

core/presentation/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,32 @@ import android.security.keystore.KeyGenParameterSpec
66
import android.security.keystore.KeyPermanentlyInvalidatedException
77
import android.security.keystore.KeyProperties
88
import android.util.Base64
9+
import androidx.appcompat.app.AppCompatActivity
910
import androidx.biometric.BiometricManager
1011
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
1112
import androidx.biometric.BiometricPrompt
1213
import androidx.core.content.ContextCompat
14+
import co.touchlab.kermit.Logger
1315
import kotlinx.coroutines.suspendCancellableCoroutine
1416
import java.security.KeyStore
1517
import javax.crypto.Cipher
1618
import javax.crypto.KeyGenerator
1719
import javax.crypto.SecretKey
1820
import javax.crypto.spec.GCMParameterSpec
1921
import kotlin.coroutines.resume
22+
import androidx.core.content.edit
2023

2124
actual class BiometricInteractor(
2225
private val context: Context,
2326
private val activityHolder: BiometricActivityHolder,
2427
) {
28+
private val logger = Logger.withTag("BiometricInteractor")
2529
private val prefs: SharedPreferences =
2630
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
2731

2832
actual suspend fun canAuthenticate(): Boolean {
29-
val mgr = BiometricManager.from(context)
30-
return mgr.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
33+
val bm = BiometricManager.from(context)
34+
return bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
3135
}
3236

3337
actual fun hasStoredPassword(): Boolean =
@@ -45,6 +49,7 @@ actual class BiometricInteractor(
4549
val secretKey = try {
4650
createOrGetKey()
4751
} catch (t: Throwable) {
52+
logger.e(t) { "Keystore failure" }
4853
return BiometricResult.Error(t.message ?: "Keystore failure")
4954
}
5055
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
@@ -53,10 +58,10 @@ actual class BiometricInteractor(
5358
return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) {
5459
is PromptOutcome.Authenticated -> {
5560
val out = auth.cipher.doFinal(password.toString().toByteArray(Charsets.UTF_8))
56-
prefs.edit()
57-
.putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP))
58-
.putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP))
59-
.apply()
61+
prefs.edit {
62+
putString(KEY_CIPHERTEXT, Base64.encodeToString(out, Base64.NO_WRAP))
63+
putString(KEY_IV, Base64.encodeToString(auth.cipher.iv, Base64.NO_WRAP))
64+
}
6065
BiometricResult.Success
6166
}
6267
is PromptOutcome.Failure -> auth.result
@@ -71,65 +76,72 @@ actual class BiometricInteractor(
7176
if (!hasStoredPassword()) {
7277
return DecryptedPasswordResult.Failure(BiometricResult.Unavailable)
7378
}
74-
val activity = activityHolder.current()
75-
?: return DecryptedPasswordResult.Failure(
76-
BiometricResult.Error("No active Activity for BiometricPrompt")
77-
)
78-
val ciphertext = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP)
79-
val iv = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP)
80-
val secretKey = try {
79+
val activity = activityHolder.current() ?: return DecryptedPasswordResult.Failure(
80+
result = BiometricResult.Error("No active Activity for BiometricPrompt")
81+
)
82+
val ciphertext: ByteArray? = Base64.decode(prefs.getString(KEY_CIPHERTEXT, null), Base64.NO_WRAP)
83+
val iv: ByteArray? = Base64.decode(prefs.getString(KEY_IV, null), Base64.NO_WRAP)
84+
val secretKey: SecretKey = try {
8185
existingKey() ?: run {
8286
clearStoredPassword()
8387
return DecryptedPasswordResult.Failure(BiometricResult.Unavailable)
8488
}
8589
} catch (t: KeyPermanentlyInvalidatedException) {
90+
logger.e(t) { "Key permanently invalidated" }
8691
clearStoredPassword()
8792
return DecryptedPasswordResult.Failure(BiometricResult.Unavailable)
8893
} catch (t: Throwable) {
94+
logger.e(t) { "Keystore failure" }
8995
return DecryptedPasswordResult.Failure(
90-
BiometricResult.Error(t.message ?: "Keystore failure")
96+
result = BiometricResult.Error(t.message ?: "Keystore failure")
9197
)
9298
}
93-
val cipher = try {
99+
val cipher: Cipher = try {
94100
Cipher.getInstance(TRANSFORMATION).apply {
95101
init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv))
96102
}
97103
} catch (t: KeyPermanentlyInvalidatedException) {
104+
logger.e(t) { "Key permanently invalidated" }
98105
clearStoredPassword()
99106
return DecryptedPasswordResult.Failure(BiometricResult.Unavailable)
100107
} catch (t: Throwable) {
108+
logger.e(t) { "Cipher init failed" }
101109
return DecryptedPasswordResult.Failure(
102-
BiometricResult.Error(t.message ?: "Cipher init failed")
110+
result = BiometricResult.Error(t.message ?: "Cipher init failed")
103111
)
104112
}
105-
return when (val auth = runPrompt(activity, cipher, title, subtitle, negativeButton)) {
113+
return when (val auth: PromptOutcome = runPrompt(activity, cipher, title, subtitle, negativeButton)) {
106114
is PromptOutcome.Authenticated -> {
107-
val plain = auth.cipher.doFinal(ciphertext)
115+
val plain: ByteArray = auth.cipher.doFinal(ciphertext)
108116
DecryptedPasswordResult.Success(plain.toString(Charsets.UTF_8))
109117
}
110118
is PromptOutcome.Failure -> DecryptedPasswordResult.Failure(auth.result)
111119
}
112120
}
113121

114122
actual fun clearStoredPassword() {
115-
prefs.edit().remove(KEY_CIPHERTEXT).remove(KEY_IV).apply()
123+
prefs.edit {
124+
remove(KEY_CIPHERTEXT)
125+
remove(KEY_IV)
126+
}
116127
runCatching {
117-
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS)
128+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
129+
keyStore.load(null)
130+
keyStore.deleteEntry(KEY_ALIAS)
118131
}
119132
}
120133

121134
private fun existingKey(): SecretKey? {
122-
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
135+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
136+
keyStore.load(null)
123137
return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
124138
}
125139

126140
private fun createOrGetKey(): SecretKey {
127141
existingKey()?.let { return it }
128142
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
129-
val spec = KeyGenParameterSpec.Builder(
130-
KEY_ALIAS,
131-
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
132-
)
143+
val spec = KeyGenParameterSpec
144+
.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
133145
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
134146
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
135147
.setUserAuthenticationRequired(true)
@@ -140,7 +152,7 @@ actual class BiometricInteractor(
140152
}
141153

142154
private suspend fun runPrompt(
143-
activity: androidx.appcompat.app.AppCompatActivity,
155+
activity: AppCompatActivity,
144156
cipher: Cipher,
145157
title: String,
146158
subtitle: String,
@@ -158,7 +170,6 @@ actual class BiometricInteractor(
158170
continuation.resume(PromptOutcome.Authenticated(resultCipher))
159171
}
160172
}
161-
162173
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
163174
val mapped = when (errorCode) {
164175
BiometricPrompt.ERROR_USER_CANCELED,
@@ -171,7 +182,6 @@ actual class BiometricInteractor(
171182
}
172183
continuation.resume(PromptOutcome.Failure(mapped))
173184
}
174-
175185
override fun onAuthenticationFailed() {
176186
// Triggered on a wrong fingerprint; system gives the user another try, so do not
177187
// resume the continuation here. The terminal callback is onAuthenticationError.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
package com.softartdev.notedelight.interactor
22

33
expect class BiometricInteractor {
4+
45
suspend fun canAuthenticate(): Boolean
6+
57
fun hasStoredPassword(): Boolean
8+
69
suspend fun encryptAndStorePassword(
710
password: CharSequence,
811
title: String,
912
subtitle: String,
1013
negativeButton: String,
1114
): BiometricResult
15+
1216
suspend fun decryptStoredPassword(
1317
title: String,
1418
subtitle: String,
1519
negativeButton: String,
1620
): DecryptedPasswordResult
21+
1722
fun clearStoredPassword()
1823
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollResult.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ data class BiometricEnrollResult(
1212
fun showLoading(): BiometricEnrollResult = copy(loading = true)
1313
fun hideLoading(): BiometricEnrollResult = copy(loading = false)
1414
fun showError(): BiometricEnrollResult = copy(isError = true)
15-
fun hideError(): BiometricEnrollResult = copy(isError = false)
1615
fun togglePasswordVisibility(): BiometricEnrollResult = copy(isPasswordVisible = !isPasswordVisible)
1716
}
1817

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricEnrollViewModel.kt

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class BiometricEnrollViewModel(
2626
private val coroutineDispatchers: CoroutineDispatchers,
2727
) : ViewModel() {
2828
private val logger = Logger.withTag(this@BiometricEnrollViewModel::class.simpleName.toString())
29+
2930
private val mutableStateFlow: MutableStateFlow<BiometricEnrollResult> =
3031
MutableStateFlow(BiometricEnrollResult())
3132
val stateFlow: StateFlow<BiometricEnrollResult> = mutableStateFlow
@@ -35,14 +36,18 @@ class BiometricEnrollViewModel(
3536
is BiometricEnrollAction.OnEditPassword -> onEditPassword(action.password)
3637
is BiometricEnrollAction.TogglePasswordVisibility -> togglePasswordVisibility()
3738
is BiometricEnrollAction.OnEnrollClick -> enroll(
38-
action.title, action.subtitle, action.negativeButton
39+
title = action.title,
40+
subtitle = action.subtitle,
41+
negativeButton = action.negativeButton
3942
)
4043
}
4144

42-
private fun onEditPassword(password: String) = viewModelScope.launch {
43-
mutableStateFlow.update(BiometricEnrollResult::hideError)
44-
mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.ENTER_PASSWORD) }
45-
mutableStateFlow.update { it.copy(password = password) }
45+
private fun onEditPassword(password: String) = mutableStateFlow.update { result ->
46+
return@update result.copy(
47+
isError = false,
48+
fieldLabel = FieldLabel.ENTER_PASSWORD,
49+
password = password
50+
)
4651
}
4752

4853
private fun togglePasswordVisibility() = viewModelScope.launch {
@@ -57,28 +62,32 @@ class BiometricEnrollViewModel(
5762
CountingIdlingRes.increment()
5863
mutableStateFlow.update(BiometricEnrollResult::showLoading)
5964
try {
60-
val password = mutableStateFlow.value.password
65+
val password: String = mutableStateFlow.value.password
6166
when {
6267
password.isEmpty() -> {
6368
mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.EMPTY_PASSWORD) }
6469
mutableStateFlow.update(BiometricEnrollResult::showError)
6570
}
66-
!checkPasswordUseCase(password) -> {
67-
mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) }
68-
mutableStateFlow.update(BiometricEnrollResult::showError)
69-
}
70-
else -> {
71-
val result = biometricInteractor.encryptAndStorePassword(
72-
password, title, subtitle, negativeButton
71+
checkPasswordUseCase(password) -> {
72+
val result: BiometricResult = biometricInteractor.encryptAndStorePassword(
73+
password = password,
74+
title = title,
75+
subtitle = subtitle,
76+
negativeButton = negativeButton
7377
)
74-
if (result is BiometricResult.Success) {
75-
withContext(coroutineDispatchers.main) {
78+
when (result) {
79+
is BiometricResult.Success -> withContext(coroutineDispatchers.main) {
7680
router.popBackStack()
7781
}
78-
} else {
79-
snackbarInteractor.showMessage(SnackbarMessage.Simple(result.toString()))
82+
else -> snackbarInteractor.showMessage(
83+
message = SnackbarMessage.Simple(result.toString())
84+
)
8085
}
8186
}
87+
else -> {
88+
mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT_PASSWORD) }
89+
mutableStateFlow.update(BiometricEnrollResult::showError)
90+
}
8291
}
8392
} catch (e: Throwable) {
8493
logger.e(e) { "Error enrolling biometric" }
@@ -89,7 +98,5 @@ class BiometricEnrollViewModel(
8998
}
9099
}
91100

92-
private fun cancel() = viewModelScope.launch {
93-
router.popBackStack()
94-
}
101+
private fun cancel() = viewModelScope.launch { router.popBackStack() }
95102
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/change/ChangeViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ChangeViewModel(
3131
private val logger = Logger.withTag(this@ChangeViewModel::class.simpleName.toString())
3232
private val mutableStateFlow: MutableStateFlow<ChangeResult> = MutableStateFlow(ChangeResult())
3333
val stateFlow: StateFlow<ChangeResult> = mutableStateFlow
34-
var autofillManager: AutofillManager? = null
34+
var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules
3535

3636
fun onAction(action: ChangeAction) = when (action) {
3737
is ChangeAction.Cancel -> cancel()
@@ -82,8 +82,8 @@ class ChangeViewModel(
8282
if (biometricInteractor.hasStoredPassword()) {
8383
biometricInteractor.clearStoredPassword()
8484
snackbarInteractor.showMessage(
85-
SnackbarMessage.Resource(
86-
SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
85+
message = SnackbarMessage.Resource(
86+
res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
8787
)
8888
)
8989
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/confirm/ConfirmViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ConfirmViewModel(
3131
value = ConfirmResult()
3232
)
3333
val stateFlow: StateFlow<ConfirmResult> = mutableStateFlow
34-
var autofillManager: AutofillManager? = null
34+
var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules
3535

3636
fun onAction(action: ConfirmAction) = when (action) {
3737
is ConfirmAction.Cancel -> cancel()
@@ -74,8 +74,8 @@ class ConfirmViewModel(
7474
if (biometricInteractor.hasStoredPassword()) {
7575
biometricInteractor.clearStoredPassword()
7676
snackbarInteractor.showMessage(
77-
SnackbarMessage.Resource(
78-
SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
77+
message = SnackbarMessage.Resource(
78+
res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
7979
)
8080
)
8181
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/enter/EnterViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class EnterViewModel(
3030
private val logger = Logger.withTag(this@EnterViewModel::class.simpleName.toString())
3131
private val mutableStateFlow: MutableStateFlow<EnterResult> = MutableStateFlow(EnterResult())
3232
val stateFlow: StateFlow<EnterResult> = mutableStateFlow
33-
var autofillManager: AutofillManager? = null
33+
var autofillManager: AutofillManager? = null //TODO wrap in interactor for get rid of `androidx.compose` deps in presentation-modules
3434

3535
fun onAction(action: EnterAction) = when (action) {
3636
is EnterAction.Cancel -> cancel()
@@ -64,8 +64,8 @@ class EnterViewModel(
6464
if (biometricInteractor.hasStoredPassword()) {
6565
biometricInteractor.clearStoredPassword()
6666
snackbarInteractor.showMessage(
67-
SnackbarMessage.Resource(
68-
SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
67+
message = SnackbarMessage.Resource(
68+
res = SnackbarTextResource.BIOMETRIC_DISABLED_PASSWORD_CHANGED
6969
)
7070
)
7171
}

0 commit comments

Comments
 (0)