Skip to content

Commit 815fb3e

Browse files
committed
refactor: improve sign-in state model and biometric activity tracking
- Refactor `SignInResult` from a data class with an enum-based state to a sealed interface hierarchy for improved type safety and state handling. - Update `SignInViewModel` and `SignInScreen` to utilize the new sealed structure and implement a `setState` helper for atomic updates. - Extract `CurrentActivityProvider` into a standalone internal class, utilizing `WeakReference` to prevent potential memory leaks of the host `FragmentActivity`. - Enhance error handling in `BiometricEnrollViewModel` to extract and display specific error messages from `BiometricResult`. - Remove the deprecated `USE_FINGERPRINT` permission from the Android manifest. - Introduce a `dispose()` method in `BiometricInteractor` for explicit lifecycle management and cleaner test execution. - Update `SignInViewModelTest` to align assertions with the new sealed state model.
1 parent 9ff71fa commit 815fb3e

8 files changed

Lines changed: 128 additions & 86 deletions

File tree

app/android/src/main/AndroidManifest.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

44
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
5-
<uses-permission android:name="android.permission.USE_FINGERPRINT" android:maxSdkVersion="28" />
65

76
<application
87
android:name=".MainApplication"

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ class SignInViewModelTest {
4949
@Test
5050
fun showSignInForm() = runTest {
5151
signInViewModel.stateFlow.test {
52-
assertEquals(SignInResult(), awaitItem())
52+
assertEquals(SignInResult.Form(), awaitItem())
5353
cancelAndIgnoreRemainingEvents()
5454
}
5555
}
5656

5757
@Test
5858
fun onSettingsClick() = runTest {
5959
signInViewModel.stateFlow.test {
60-
assertEquals(SignInResult(), awaitItem())
60+
assertEquals(SignInResult.Form(), awaitItem())
6161

6262
signInViewModel.onAction(SignInAction.OnSettingsClick)
6363
Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Settings)
@@ -69,7 +69,7 @@ class SignInViewModelTest {
6969
@Test
7070
fun navMain() = runTest {
7171
signInViewModel.stateFlow.test {
72-
assertEquals(SignInResult(), awaitItem())
72+
assertEquals(SignInResult.Form(), awaitItem())
7373

7474
val pass = StubEditable("pass")
7575
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
@@ -84,10 +84,10 @@ class SignInViewModelTest {
8484
@Test
8585
fun showEmptyPassError() = runTest {
8686
signInViewModel.stateFlow.test {
87-
assertEquals(SignInResult(), awaitItem())
87+
assertEquals(SignInResult.Form(), awaitItem())
8888

8989
signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable("")))
90-
assertEquals(SignInResult.State.ShowEmptyPassError, awaitItem().state)
90+
assertTrue(awaitItem() is SignInResult.Error.EmptyPass)
9191

9292
cancelAndIgnoreRemainingEvents()
9393
}
@@ -96,12 +96,12 @@ class SignInViewModelTest {
9696
@Test
9797
fun showIncorrectPassError() = runTest {
9898
signInViewModel.stateFlow.test {
99-
assertEquals(SignInResult(), awaitItem())
99+
assertEquals(SignInResult.Form(), awaitItem())
100100

101101
val pass = StubEditable("pass")
102102
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false)
103103
signInViewModel.onAction(SignInAction.OnSignInClick(pass))
104-
assertEquals(SignInResult.State.ShowIncorrectPassError, awaitItem().state)
104+
assertTrue(awaitItem() is SignInResult.Error.IncorrectPass)
105105

106106
cancelAndIgnoreRemainingEvents()
107107
}
@@ -110,7 +110,7 @@ class SignInViewModelTest {
110110
@Test
111111
fun showError() = runTest {
112112
signInViewModel.stateFlow.test {
113-
assertEquals(SignInResult(), awaitItem())
113+
assertEquals(SignInResult.Form(), awaitItem())
114114

115115
val throwable = Throwable()
116116
Mockito.`when`(mockCheckPasswordUseCase(anyObject())).thenThrow(throwable)
@@ -142,7 +142,7 @@ class SignInViewModelTest {
142142
.thenReturn(DecryptedPasswordResult.Success(pass))
143143
Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true)
144144
signInViewModel.stateFlow.test {
145-
assertEquals(SignInResult(), awaitItem())
145+
assertEquals(SignInResult.Form(), awaitItem())
146146
signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c"))
147147
Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main)
148148
cancelAndIgnoreRemainingEvents()

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

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package com.softartdev.notedelight.interactor
22

3-
import android.app.Activity
43
import android.app.Application
54
import android.content.Context
65
import android.content.SharedPreferences
76
import android.os.Build
8-
import android.os.Bundle
97
import android.security.keystore.KeyGenParameterSpec
108
import android.security.keystore.KeyPermanentlyInvalidatedException
119
import android.security.keystore.KeyProperties
@@ -134,6 +132,9 @@ actual class BiometricInteractor(context: Context) {
134132
}
135133
}
136134

135+
/** Test/lifecycle hook: stops listening for Activity events. Not called automatically. */
136+
fun dispose() = activityProvider.dispose()
137+
137138
private fun existingKey(): SecretKey? {
138139
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
139140
return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
@@ -210,32 +211,6 @@ actual class BiometricInteractor(context: Context) {
210211
data class Failure(val result: BiometricResult) : PromptOutcome
211212
}
212213

213-
private class CurrentActivityProvider(application: Application) : Application.ActivityLifecycleCallbacks {
214-
var current: FragmentActivity? = null
215-
private set
216-
217-
init {
218-
application.registerActivityLifecycleCallbacks(this)
219-
}
220-
221-
override fun onActivityResumed(activity: Activity) {
222-
current = activity as? FragmentActivity
223-
}
224-
225-
override fun onActivityPaused(activity: Activity) {
226-
if (activity === current) current = null
227-
}
228-
229-
override fun onActivityDestroyed(activity: Activity) {
230-
if (activity === current) current = null
231-
}
232-
233-
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
234-
override fun onActivityStarted(activity: Activity) = Unit
235-
override fun onActivityStopped(activity: Activity) = Unit
236-
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
237-
}
238-
239214
companion object {
240215
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
241216
private const val KEY_ALIAS = "notedelight_biometric_key"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
import android.app.Activity
4+
import android.app.Application
5+
import android.os.Bundle
6+
import androidx.fragment.app.FragmentActivity
7+
import co.touchlab.kermit.Logger
8+
import java.lang.ref.WeakReference
9+
10+
/**
11+
* Tracks the currently `RESUMED` `FragmentActivity` for app-scoped components that need an
12+
* Activity host (e.g. `BiometricPrompt`). Holds the Activity in a `WeakReference` so a paused
13+
* Activity awaiting GC cannot keep its window alive through this provider.
14+
*
15+
* Registered as an [Application.ActivityLifecycleCallbacks] for the whole process at construction;
16+
* call [dispose] to unregister (mainly useful for tests — singletons normally live for the lifetime
17+
* of the process and the framework drops the registration when the process dies).
18+
*/
19+
internal class CurrentActivityProvider(
20+
private val application: Application
21+
) : Application.ActivityLifecycleCallbacks {
22+
private val logger = Logger.withTag("CurrentActivityProvider")
23+
private var ref: WeakReference<FragmentActivity>? = null
24+
25+
val current: FragmentActivity?
26+
get() = ref?.get()
27+
28+
init {
29+
application.registerActivityLifecycleCallbacks(this)
30+
}
31+
32+
fun dispose() {
33+
application.unregisterActivityLifecycleCallbacks(this)
34+
ref?.clear()
35+
ref = null
36+
}
37+
38+
override fun onActivityResumed(activity: Activity) {
39+
logger.i { "onActivityResumed: ${activity::class.java.simpleName}" }
40+
if (activity is FragmentActivity) ref = WeakReference(activity)
41+
}
42+
43+
override fun onActivityPaused(activity: Activity) {
44+
logger.i { "onActivityPaused: ${activity::class.java.simpleName}" }
45+
if (ref?.get() === activity) ref = null
46+
}
47+
48+
override fun onActivityDestroyed(activity: Activity) {
49+
logger.i { "onActivityDestroyed: ${activity::class.java.simpleName}" }
50+
if (ref?.get() === activity) ref = null
51+
}
52+
53+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
54+
logger.i { "onActivityCreated: ${activity::class.java.simpleName}" }
55+
}
56+
57+
override fun onActivityStarted(activity: Activity) {
58+
logger.i { "onActivityStarted: ${activity::class.java.simpleName}" }
59+
}
60+
override fun onActivityStopped(activity: Activity) {
61+
logger.i { "onActivityStopped: ${activity::class.java.simpleName}" }
62+
}
63+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
64+
logger.i { "onActivitySaveInstanceState: ${activity::class.java.simpleName}" }
65+
}
66+
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,14 @@ class BiometricEnrollViewModel(
7979
is BiometricResult.Success -> withContext(coroutineDispatchers.main) {
8080
router.popBackStack()
8181
}
82-
else -> snackbarInteractor.showMessage(
83-
message = SnackbarMessage.Simple(result.toString())
84-
)
82+
else -> {
83+
val resultMessage: String = when (result) {
84+
is BiometricResult.Error -> result.message
85+
else -> result.toString()
86+
}
87+
logger.e { resultMessage }
88+
snackbarInteractor.showMessage(SnackbarMessage.Simple(resultMessage))
89+
}
8590
}
8691
}
8792
else -> {
Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
package com.softartdev.notedelight.presentation.signin
22

3-
data class SignInResult(
4-
val state: State = State.ShowSignInForm,
5-
val biometricVisible: Boolean = false,
6-
) {
7-
val isError: Boolean
8-
get() = state.isError
9-
10-
enum class State(val isError: Boolean = false) {
11-
ShowSignInForm,
12-
ShowProgress,
13-
ShowEmptyPassError(isError = true),
14-
ShowIncorrectPassError(isError = true),
15-
ShowBiometricError(isError = true),
3+
sealed interface SignInResult {
4+
5+
val biometricVisible: Boolean
6+
7+
data class Form(override val biometricVisible: Boolean = false) : SignInResult
8+
9+
data class Progress(override val biometricVisible: Boolean = false) : SignInResult
10+
11+
sealed interface Error : SignInResult {
12+
13+
data class EmptyPass(override val biometricVisible: Boolean = false) : Error
14+
15+
data class IncorrectPass(override val biometricVisible: Boolean = false) : Error
16+
17+
data class Biometric(override val biometricVisible: Boolean = false) : Error
1618
}
1719
}

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class SignInViewModel(
2323
) : ViewModel() {
2424
private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString())
2525

26-
private val mutableStateFlow: MutableStateFlow<SignInResult> = MutableStateFlow(SignInResult())
26+
private val mutableStateFlow: MutableStateFlow<SignInResult> = MutableStateFlow(SignInResult.Form())
2727
val stateFlow: StateFlow<SignInResult> = mutableStateFlow
2828

2929
var autofillManager: AutofillManager? = null
@@ -41,64 +41,60 @@ class SignInViewModel(
4141

4242
private fun refreshBiometric() = viewModelScope.launch {
4343
val visible: Boolean = biometricInteractor.hasStoredPassword() && biometricInteractor.canAuthenticate()
44-
mutableStateFlow.update { it.copy(biometricVisible = visible) }
44+
mutableStateFlow.value = SignInResult.Form(biometricVisible = visible)
4545
}
4646

4747
private fun signInWithBiometric(title: String, subtitle: String, negativeButton: String) = viewModelScope.launch {
4848
CountingIdlingRes.increment()
49-
mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) }
49+
setState { SignInResult.Progress(it) }
5050
try {
5151
when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(title, subtitle, negativeButton)) {
52-
is DecryptedPasswordResult.Success -> mutableStateFlow.update {
53-
it.copy(state = signInInternal(res.password))
54-
}
52+
is DecryptedPasswordResult.Success -> setState { signInInternal(res.password, it) }
5553
is DecryptedPasswordResult.Failure -> when (res.result) {
56-
BiometricResult.Cancelled -> mutableStateFlow.update {
57-
it.copy(state = SignInResult.State.ShowSignInForm)
58-
}
54+
BiometricResult.Cancelled -> setState { SignInResult.Form(it) }
5955
BiometricResult.Unavailable -> {
6056
biometricInteractor.clearStoredPassword()
61-
mutableStateFlow.update {
62-
it.copy(state = SignInResult.State.ShowSignInForm, biometricVisible = false)
63-
}
64-
}
65-
else -> mutableStateFlow.update {
66-
it.copy(state = SignInResult.State.ShowBiometricError)
57+
mutableStateFlow.value = SignInResult.Form(biometricVisible = false)
6758
}
59+
else -> setState { SignInResult.Error.Biometric(it) }
6860
}
6961
}
7062
} catch (error: Throwable) {
7163
logger.e(error) { "Error during biometric sign in" }
7264
router.navigate(route = AppNavGraph.ErrorDialog(message = error.message))
73-
mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) }
65+
setState { SignInResult.Form(it) }
7466
} finally {
7567
CountingIdlingRes.decrement()
7668
}
7769
}
7870

7971
private fun signIn(pass: CharSequence) = viewModelScope.launch {
8072
CountingIdlingRes.increment()
81-
mutableStateFlow.update { it.copy(state = SignInResult.State.ShowProgress) }
73+
setState { SignInResult.Progress(it) }
8274
try {
83-
val nextState: SignInResult.State = signInInternal(pass)
84-
mutableStateFlow.update { it.copy(state = nextState) }
75+
setState { signInInternal(pass, it) }
8576
} catch (error: Throwable) {
8677
logger.e(error) { "Error during sign in" }
8778
autofillManager?.cancel()
8879
router.navigate(route = AppNavGraph.ErrorDialog(message = error.message))
89-
mutableStateFlow.update { it.copy(state = SignInResult.State.ShowSignInForm) }
80+
setState { SignInResult.Form(it) }
9081
} finally {
9182
CountingIdlingRes.decrement()
9283
}
9384
}
9485

95-
private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when {
96-
pass.isEmpty() -> SignInResult.State.ShowEmptyPassError
86+
private suspend fun signInInternal(pass: CharSequence, biometricVisible: Boolean): SignInResult = when {
87+
pass.isEmpty() -> SignInResult.Error.EmptyPass(biometricVisible)
9788
checkPasswordUseCase(pass) -> {
9889
autofillManager?.commit()
9990
router.navigateClearingBackStack(AppNavGraph.Main)
100-
SignInResult.State.ShowSignInForm
91+
SignInResult.Form(biometricVisible)
10192
}
102-
else -> SignInResult.State.ShowIncorrectPassError
93+
else -> SignInResult.Error.IncorrectPass(biometricVisible)
94+
}
95+
96+
// Atomically rewrites the SignInResult while preserving the current biometricVisible flag.
97+
private inline fun setState(transform: (biometricVisible: Boolean) -> SignInResult) {
98+
mutableStateFlow.update { transform(it.biometricVisible) }
10399
}
104100
}

core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,17 @@ fun SignInScreen(signInViewModel: SignInViewModel) {
6969
LaunchedEffect(signInViewModel) {
7070
signInViewModel.onAction(SignInAction.RefreshBiometric)
7171
}
72-
val result: SignInResult = signInResultState.value
7372
SignInScreenBody(
74-
showLoading = result.state == SignInResult.State.ShowProgress,
73+
showLoading = signInResultState.value is SignInResult.Progress,
7574
passwordState = passwordState,
76-
labelResource = when (result.state) {
77-
SignInResult.State.ShowEmptyPassError -> Res.string.empty_password
78-
SignInResult.State.ShowIncorrectPassError -> Res.string.incorrect_password
79-
SignInResult.State.ShowBiometricError -> Res.string.biometric_error
75+
labelResource = when (signInResultState.value) {
76+
is SignInResult.Error.EmptyPass -> Res.string.empty_password
77+
is SignInResult.Error.IncorrectPass -> Res.string.incorrect_password
78+
is SignInResult.Error.Biometric -> Res.string.biometric_error
8079
else -> Res.string.enter_password
8180
},
82-
isError = result.isError,
83-
biometricVisible = result.biometricVisible,
81+
isError = signInResultState.value is SignInResult.Error,
82+
biometricVisible = signInResultState.value.biometricVisible,
8483
onAction = signInViewModel::onAction,
8584
)
8685
}

0 commit comments

Comments
 (0)