From ce23b58d5f921acb0ac3a4c9ddf0748a2c9b8a22 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 16:07:16 +0400 Subject: [PATCH 1/5] core/signin: simplify state and update biometric docs --- .../presentation/signin/SignInResult.kt | 18 +++------ .../presentation/signin/SignInViewModel.kt | 39 ++++++++++++------- .../notedelight/ui/signin/SignInScreen.kt | 13 ++++--- feature/biometric/domain/README.md | 20 +++++----- 4 files changed, 48 insertions(+), 42 deletions(-) diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt index 4e1eda7a..5359d2a7 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt @@ -1,18 +1,12 @@ package com.softartdev.notedelight.presentation.signin data class SignInResult( - val state: State = State.Form, + val loading: Boolean = false, + val errorType: ErrorType? = null, val biometricVisible: Boolean = false, -) { - sealed interface State { - data object Form : State +) - data object Progress : State - - sealed interface Error : State { - data object EmptyPass : Error - - data object IncorrectPass : Error - } - } +enum class ErrorType { + EMPTY_PASSWORD, + INCORRECT_PASSWORD, } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt index 9ddee1c6..506d5ee7 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt @@ -55,7 +55,7 @@ class SignInViewModel( biometricPlatformWrapper: BiometricPlatformWrapper, ) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } + mutableStateFlow.update { it.copy(loading = true, errorType = null) } try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword( title = title, @@ -64,27 +64,27 @@ class SignInViewModel( biometricPlatformWrapper = biometricPlatformWrapper, )) { is DecryptedPasswordResult.Success -> mutableStateFlow.update { - it.copy(state = signInInternal(res.password)) + it.updateFromSignInInternal(signInInternal(res.password)) } is DecryptedPasswordResult.Cancelled -> mutableStateFlow.update { - it.copy(state = SignInResult.State.Form) + it.copy(loading = false, errorType = null) } is DecryptedPasswordResult.Unavailable -> { biometricInteractor.clearStoredPassword() mutableStateFlow.update { - it.copy(state = SignInResult.State.Form, biometricVisible = false) + it.copy(loading = false, errorType = null, biometricVisible = false) } } is DecryptedPasswordResult.Failure -> { logger.e { res.message } snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } + mutableStateFlow.update { it.copy(loading = false, errorType = null) } } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } + mutableStateFlow.update { it.copy(loading = false, errorType = null) } } finally { CountingIdlingRes.decrement() } @@ -92,27 +92,38 @@ class SignInViewModel( private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) } + mutableStateFlow.update { it.copy(loading = true, errorType = null) } try { - val nextState: SignInResult.State = signInInternal(pass) - mutableStateFlow.update { it.copy(state = nextState) } + val nextState: SignInInternalResult = signInInternal(pass) + mutableStateFlow.update { it.updateFromSignInInternal(nextState) } } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(state = SignInResult.State.Form) } + mutableStateFlow.update { it.copy(loading = false, errorType = null) } } finally { CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when { - pass.isEmpty() -> SignInResult.State.Error.EmptyPass + private suspend fun signInInternal(pass: CharSequence): SignInInternalResult = when { + pass.isEmpty() -> SignInInternalResult.Error(ErrorType.EMPTY_PASSWORD) checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInResult.State.Form + SignInInternalResult.Success } - else -> SignInResult.State.Error.IncorrectPass + else -> SignInInternalResult.Error(ErrorType.INCORRECT_PASSWORD) } } + +private sealed interface SignInInternalResult { + data object Success : SignInInternalResult + data class Error(val type: ErrorType) : SignInInternalResult +} + +private fun SignInResult.updateFromSignInInternal(result: SignInInternalResult): SignInResult = when (result) { + SignInInternalResult.Success -> copy(loading = false, errorType = null) + is SignInInternalResult.Error -> copy(loading = false, errorType = result.type) +} + diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt index 1c062f87..174c2a90 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.softartdev.notedelight.interactor.BiometricPlatformWrapper import com.softartdev.notedelight.presentation.signin.SignInAction +import com.softartdev.notedelight.presentation.signin.ErrorType import com.softartdev.notedelight.presentation.signin.SignInResult import com.softartdev.notedelight.presentation.signin.SignInViewModel import com.softartdev.notedelight.ui.PasswordField @@ -71,14 +72,14 @@ fun SignInScreen(signInViewModel: SignInViewModel) { signInViewModel.onAction(SignInAction.RefreshBiometric) } SignInScreenBody( - showLoading = signInResultState.value.state is SignInResult.State.Progress, + showLoading = signInResultState.value.loading, passwordState = passwordState, - labelResource = when (signInResultState.value.state) { - is SignInResult.State.Error.EmptyPass -> Res.string.empty_password - is SignInResult.State.Error.IncorrectPass -> Res.string.incorrect_password - else -> Res.string.enter_password + labelResource = when (signInResultState.value.errorType) { + ErrorType.EMPTY_PASSWORD -> Res.string.empty_password + ErrorType.INCORRECT_PASSWORD -> Res.string.incorrect_password + null -> Res.string.enter_password }, - isError = signInResultState.value.state is SignInResult.State.Error, + isError = signInResultState.value.errorType != null, biometricVisible = signInResultState.value.biometricVisible, onAction = signInViewModel::onAction, ) diff --git a/feature/biometric/domain/README.md b/feature/biometric/domain/README.md index 6f314f98..f8656bde 100644 --- a/feature/biometric/domain/README.md +++ b/feature/biometric/domain/README.md @@ -4,7 +4,7 @@ Biometric authentication domain module — platform-agnostic contract plus platf ## Overview -Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types, and the `ActivityProvider` expect class used to pass the Android host Activity to the biometric prompt from Compose. +Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types, and the `BiometricPlatformWrapper` expect class used to pass the Android host Activity to the biometric prompt from Compose. ## API @@ -19,19 +19,19 @@ expect class BiometricInteractor { title: String, subtitle: String, negativeButton: String, - activityProvider: ActivityProvider, + biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, - activityProvider: ActivityProvider, + biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult suspend fun clearStoredPassword() } ``` -The `activityProvider` parameter is created in `@Composable` functions via `rememberActivityProvider()` (defined in `core:ui`) and stored as a var property on the ViewModel. On Android it carries the current `FragmentActivity`; on all other platforms it is an empty stub. +The `biometricPlatformWrapper` parameter is created in `@Composable` functions via `rememberBiometricPlatformWrapper()` (defined in `core:ui`) and stored as a var property on the ViewModel. On Android it carries the current `FragmentActivity`; on all other platforms it is an empty stub. ### `BiometricResult` @@ -58,15 +58,15 @@ sealed interface DecryptedPasswordResult { `Cancelled` and `Unavailable` are modelled as separate objects so callers can distinguish intent (user cancelled voluntarily vs. hardware/key no longer valid) without nesting `BiometricResult` inside `DecryptedPasswordResult`. -### `ActivityProvider` +### `BiometricPlatformWrapper` ```kotlin -expect class ActivityProvider -// Android actual: actual class ActivityProvider(val activity: FragmentActivity) -// iOS/JVM/wasmJs: actual class ActivityProvider (empty stub) +expect class BiometricPlatformWrapper +// Android actual: actual class BiometricPlatformWrapper(val activity: FragmentActivity) +// iOS/JVM/wasmJs: actual class BiometricPlatformWrapper (empty stub) ``` -Created from a Composable using `rememberActivityProvider()` (in `core:ui`) and stored as a var property on the ViewModel. Passed to `encryptAndStorePassword` / `decryptStoredPassword` so that the Android implementation can instantiate `BiometricPrompt`. +Created from a Composable using `rememberBiometricPlatformWrapper()` (in `core:ui`) and stored as a var property on the ViewModel. Passed to `encryptAndStorePassword` / `decryptStoredPassword` so that the Android implementation can instantiate `BiometricPrompt`. ## Platform Implementations @@ -96,7 +96,7 @@ Created from a Composable using `rememberActivityProvider()` (in `core:ui`) and **Key design decisions**: - `BiometricPrompt.authenticate()` must run on the main thread — `runPrompt()` uses `withContext(Dispatchers.Main.immediate)`. -- `ActivityProvider` is supplied from the composable layer (`rememberActivityProvider()` in `core:ui` uses `LocalContext.current as FragmentActivity`), stored as a var property on the ViewModel (same pattern as `autofillManager`). This replaces the previous `CurrentActivityProvider` (an `ActivityLifecycleCallbacks` singleton) which was broken because no lifecycle callbacks fired after the Koin singleton was created at app startup. +- `BiometricPlatformWrapper` is supplied from the composable layer (`rememberBiometricPlatformWrapper()` in `core:ui` uses `LocalContext.current as FragmentActivity`), stored as a var property on the ViewModel (same pattern as `autofillManager`). This replaces the previous `CurrentActivityProvider` (an `ActivityLifecycleCallbacks` singleton) which was broken because no lifecycle callbacks fired after the Koin singleton was created at app startup. - `setInvalidatedByBiometricEnrollment(true)` is wrapped in `Build.VERSION.SDK_INT >= Build.VERSION_CODES.N` (API 24 lint requirement; minSdk is 23). **`BiometricCredentialsStore`** (internal, Android-only): From 559161869e0797dd88fa3d2102b57db60ec17893 Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 16:54:48 +0400 Subject: [PATCH 2/5] refactor: update SignInViewModel error assertions in tests - Update `SignInViewModelTest` to use the `errorType` property for verifying error states. - Replace subclass-based assertions for `EmptyPass` and `IncorrectPass` with `ErrorType.EMPTY_PASSWORD` and `ErrorType.INCORRECT_PASSWORD` equality checks. --- .../notedelight/presentation/signin/SignInViewModelTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt index 70e12706..5d955a52 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt @@ -91,7 +91,7 @@ class SignInViewModelTest { assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable(""))) - assertTrue(awaitItem().state is SignInResult.State.Error.EmptyPass) + assertEquals(ErrorType.EMPTY_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -105,7 +105,7 @@ class SignInViewModelTest { val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.onAction(SignInAction.OnSignInClick(pass)) - assertTrue(awaitItem().state is SignInResult.State.Error.IncorrectPass) + assertEquals(ErrorType.INCORRECT_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } From ba4548eb788cb3c187ca2cc403e6293d7ae2fa6e Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 17:26:22 +0400 Subject: [PATCH 3/5] refactor: improve sign-in state handling and simplify ViewModel logic - Nest the `ErrorType` enum within the `SignInResult` class. - Add helper functions to `SignInResult` for common state transformations, such as `showLoading`, `hideErrors`, and specific error states. - Refactor `SignInViewModel` to use these helper functions for state updates, improving readability. - Remove the internal `SignInInternalResult` sealed interface and its associated mapping logic. - Update `SignInScreen` and `SignInViewModelTest` to reference the relocated `SignInResult.ErrorType`. --- .../signin/SignInViewModelTest.kt | 14 +++--- .../presentation/signin/SignInResult.kt | 12 +++-- .../presentation/signin/SignInViewModel.kt | 44 ++++++------------- .../notedelight/ui/signin/SignInScreen.kt | 5 +-- 4 files changed, 31 insertions(+), 44 deletions(-) diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt index 5d955a52..dbdcf218 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModelTest.kt @@ -39,7 +39,9 @@ class SignInViewModelTest { private val mockRouter = Mockito.mock(Router::class.java) private val mockAutofillManager = Mockito.mock(AutofillManager::class.java) private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) - + private val biometricPlatformWrapper: BiometricPlatformWrapper = BiometricPlatformWrapper( + activity = Mockito.mock(FragmentActivity::class.java) + ) private lateinit var signInViewModel: SignInViewModel @Before @@ -91,7 +93,7 @@ class SignInViewModelTest { assertEquals(SignInResult(), awaitItem()) signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable(""))) - assertEquals(ErrorType.EMPTY_PASSWORD, awaitItem().errorType) + assertEquals(SignInResult.ErrorType.EMPTY_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -105,7 +107,7 @@ class SignInViewModelTest { val pass = StubEditable("pass") Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(false) signInViewModel.onAction(SignInAction.OnSignInClick(pass)) - assertEquals(ErrorType.INCORRECT_PASSWORD, awaitItem().errorType) + assertEquals(SignInResult.ErrorType.INCORRECT_PASSWORD, awaitItem().errorType) cancelAndIgnoreRemainingEvents() } @@ -147,7 +149,7 @@ class SignInViewModelTest { Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) signInViewModel.stateFlow.test { assertEquals(SignInResult(), awaitItem()) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) cancelAndIgnoreRemainingEvents() } @@ -159,7 +161,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Unavailable) signInViewModel.stateFlow.test { assertFalse(awaitItem().biometricVisible) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockBiometricInteractor).clearStoredPassword() cancelAndIgnoreRemainingEvents() } @@ -172,7 +174,7 @@ class SignInViewModelTest { .thenReturn(DecryptedPasswordResult.Failure(errorMessage)) signInViewModel.stateFlow.test { assertEquals(SignInResult(), awaitItem()) - signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", BiometricPlatformWrapper(Mockito.mock(FragmentActivity::class.java)))) + signInViewModel.onAction(SignInAction.OnBiometricClick("t", "s", "c", biometricPlatformWrapper)) Mockito.verify(mockSnackbarInteractor).showMessage(SnackbarMessage.Simple(errorMessage)) cancelAndIgnoreRemainingEvents() } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt index 5359d2a7..cfca6d17 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInResult.kt @@ -4,9 +4,13 @@ data class SignInResult( val loading: Boolean = false, val errorType: ErrorType? = null, val biometricVisible: Boolean = false, -) +) { + enum class ErrorType { EMPTY_PASSWORD, INCORRECT_PASSWORD } -enum class ErrorType { - EMPTY_PASSWORD, - INCORRECT_PASSWORD, + fun showLoading(): SignInResult = copy(loading = true) + fun hideLoading(): SignInResult = copy(loading = false) + fun hideBiometric(): SignInResult = copy(biometricVisible = true) + fun showEmptyPasswordError(): SignInResult = copy(errorType = ErrorType.EMPTY_PASSWORD) + fun showIncorrectPasswordError(): SignInResult = copy(errorType = ErrorType.INCORRECT_PASSWORD) + fun hideErrors(): SignInResult = copy(errorType = null) } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt index 506d5ee7..34be71d4 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInViewModel.kt @@ -55,7 +55,8 @@ class SignInViewModel( biometricPlatformWrapper: BiometricPlatformWrapper, ) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(loading = true, errorType = null) } + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) try { when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword( title = title, @@ -63,67 +64,48 @@ class SignInViewModel( negativeButton = negativeButton, biometricPlatformWrapper = biometricPlatformWrapper, )) { - is DecryptedPasswordResult.Success -> mutableStateFlow.update { - it.updateFromSignInInternal(signInInternal(res.password)) - } - is DecryptedPasswordResult.Cancelled -> mutableStateFlow.update { - it.copy(loading = false, errorType = null) - } + is DecryptedPasswordResult.Success -> signInInternal(pass = res.password) + is DecryptedPasswordResult.Cancelled -> Unit is DecryptedPasswordResult.Unavailable -> { biometricInteractor.clearStoredPassword() - mutableStateFlow.update { - it.copy(loading = false, errorType = null, biometricVisible = false) - } + mutableStateFlow.update(SignInResult::hideBiometric) } is DecryptedPasswordResult.Failure -> { logger.e { res.message } snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message)) - mutableStateFlow.update { it.copy(loading = false, errorType = null) } } } } catch (error: Throwable) { logger.e(error) { "Error during biometric sign in" } router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(loading = false, errorType = null) } } finally { + mutableStateFlow.update(SignInResult::hideLoading) CountingIdlingRes.decrement() } } private fun signIn(pass: CharSequence) = viewModelScope.launch { CountingIdlingRes.increment() - mutableStateFlow.update { it.copy(loading = true, errorType = null) } + mutableStateFlow.update(SignInResult::hideErrors) + mutableStateFlow.update(SignInResult::showLoading) try { - val nextState: SignInInternalResult = signInInternal(pass) - mutableStateFlow.update { it.updateFromSignInInternal(nextState) } + signInInternal(pass) } catch (error: Throwable) { logger.e(error) { "Error during sign in" } autofillManager?.cancel() router.navigate(route = AppNavGraph.ErrorDialog(message = error.message)) - mutableStateFlow.update { it.copy(loading = false, errorType = null) } } finally { + mutableStateFlow.update(SignInResult::hideLoading) CountingIdlingRes.decrement() } } - private suspend fun signInInternal(pass: CharSequence): SignInInternalResult = when { - pass.isEmpty() -> SignInInternalResult.Error(ErrorType.EMPTY_PASSWORD) + private suspend fun signInInternal(pass: CharSequence) = when { + pass.isEmpty() -> mutableStateFlow.update(SignInResult::showEmptyPasswordError) checkPasswordUseCase(pass) -> { autofillManager?.commit() router.navigateClearingBackStack(AppNavGraph.Main) - SignInInternalResult.Success } - else -> SignInInternalResult.Error(ErrorType.INCORRECT_PASSWORD) + else -> mutableStateFlow.update(SignInResult::showIncorrectPasswordError) } } - -private sealed interface SignInInternalResult { - data object Success : SignInInternalResult - data class Error(val type: ErrorType) : SignInInternalResult -} - -private fun SignInResult.updateFromSignInInternal(result: SignInInternalResult): SignInResult = when (result) { - SignInInternalResult.Success -> copy(loading = false, errorType = null) - is SignInInternalResult.Error -> copy(loading = false, errorType = result.type) -} - diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt index 174c2a90..9086c6ca 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/signin/SignInScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.softartdev.notedelight.interactor.BiometricPlatformWrapper import com.softartdev.notedelight.presentation.signin.SignInAction -import com.softartdev.notedelight.presentation.signin.ErrorType import com.softartdev.notedelight.presentation.signin.SignInResult import com.softartdev.notedelight.presentation.signin.SignInViewModel import com.softartdev.notedelight.ui.PasswordField @@ -75,8 +74,8 @@ fun SignInScreen(signInViewModel: SignInViewModel) { showLoading = signInResultState.value.loading, passwordState = passwordState, labelResource = when (signInResultState.value.errorType) { - ErrorType.EMPTY_PASSWORD -> Res.string.empty_password - ErrorType.INCORRECT_PASSWORD -> Res.string.incorrect_password + SignInResult.ErrorType.EMPTY_PASSWORD -> Res.string.empty_password + SignInResult.ErrorType.INCORRECT_PASSWORD -> Res.string.incorrect_password null -> Res.string.enter_password }, isError = signInResultState.value.errorType != null, From e90875ac6252dd3f406d4b11f6af104899d50eef Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Thu, 30 Apr 2026 20:49:05 +0400 Subject: [PATCH 4/5] feat: add confirmation dialog for disabling biometric sign-in - Implement `BiometricDisableConfirmationDialog` and its corresponding `BiometricDisableViewModel` to handle user confirmation. - Introduce `DisableBiometricUseCase` to encapsulate the logic for clearing stored biometric credentials. - Refactor `SettingsViewModel` to trigger the confirmation dialog and await the result via a `Channel` before clearing stored passwords. - Register the new dialog in the navigation graph and update dependency injection modules. - Add localized string resources for the confirmation dialog in English and Russian. - Expand test coverage with UI tests for the new dialog and unit tests in `SettingsViewModelTest` to verify the confirmation flow. - Update test infrastructure with new test tags and helper methods for the biometric disable screen. --- .../adaptive/AdaptiveInteractorTest.kt | 4 +- .../settings/SettingsViewModelTest.kt | 57 +++++++++++++++++ .../notedelight/navigation/AppNavGraph.kt | 3 + .../settings/SettingsViewModel.kt | 16 ++++- .../kotlin/com/softartdev/notedelight/ext.kt | 13 ++-- .../softartdev/notedelight/ui/BaseTestCase.kt | 5 ++ .../BiometricDisableConfirmationDialog.kt | 19 ++++++ .../BiometricDisableConfirmationDialogTest.kt | 63 ++++++++++++++++++ .../composeResources/values-ru/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../kotlin/com/softartdev/notedelight/App.kt | 6 +- .../notedelight/di/sharedModules.kt | 4 ++ .../BiometricDisableConfirmationDialog.kt | 64 +++++++++++++++++++ .../softartdev/notedelight/util/TestTags.kt | 1 + .../biometric/DisableBiometricUseCase.kt | 15 +++++ .../biometric/BiometricDisableViewModel.kt | 29 +++++++++ 16 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt create mode 100644 core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt create mode 100644 core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialog.kt create mode 100644 feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt create mode 100644 feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt index cfa7f973..d1479e8d 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt @@ -27,10 +27,11 @@ import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel import com.softartdev.notedelight.presentation.settings.SettingsViewModel import com.softartdev.notedelight.repository.SafeRepo +import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase +import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.note.CreateNoteUseCase import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase import com.softartdev.notedelight.usecase.note.SaveNoteUseCase -import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase import com.softartdev.notedelight.usecase.settings.ImportDatabaseUseCase @@ -118,6 +119,7 @@ class AdaptiveInteractorTest { snackbarInteractor = mockSnackbarInteractor, router = mockRouter, revealFileListUseCase = revealFileListUseCase, + disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, biometricInteractor = mockBiometricInteractor, diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt index 1df324f6..eedf3f4f 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt @@ -16,6 +16,7 @@ import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule import com.softartdev.notedelight.repository.SafeRepo +import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase @@ -60,6 +61,7 @@ class SettingsViewModelTest { snackbarInteractor = mockSnackbarInteractor, router = mockRouter, revealFileListUseCase = RevealFileListUseCase(), + disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, biometricInteractor = mockBiometricInteractor, @@ -163,6 +165,52 @@ class SettingsViewModelTest { Mockito.verifyNoMoreInteractions(mockRouter) } + @Test + fun changeBiometricEnableShowsEnrollDialog() = runTest { + settingsViewModel.onAction(SettingsAction.ChangeBiometric(true)) + + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricEnrollDialog) + Mockito.verifyNoMoreInteractions(mockRouter) + } + + @Test + fun changeBiometricDisableShowsConfirmationDialogAndKeepsStoredPasswordOnCancel() = runTest { + stubBiometricEnabled() + settingsViewModel.updateSwitches() + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + + DisableBiometricUseCase.dialogChannel.send(false) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + Mockito.verify(mockBiometricInteractor, Mockito.never()).clearStoredPassword() + Mockito.verifyNoMoreInteractions(mockRouter) + } + + @Test + fun changeBiometricDisableClearsStoredPasswordAfterConfirmation() = runTest { + stubBiometricEnabled() + settingsViewModel.updateSwitches() + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) + Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + + DisableBiometricUseCase.dialogChannel.send(true) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) + Mockito.verify(mockBiometricInteractor).clearStoredPassword() + Mockito.verifyNoMoreInteractions(mockRouter) + } + @Test fun changePasswordChangePasswordDialog() = runTest { Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) @@ -265,4 +313,13 @@ class SettingsViewModelTest { } assertFalse(settingsViewModel.stateFlow.value.fileListVisible) } + + private fun stubBiometricEnabled() { + Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) + Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + runBlocking { + Mockito.`when`(mockBiometricInteractor.canAuthenticate()).thenReturn(true) + Mockito.`when`(mockBiometricInteractor.hasStoredPassword()).thenReturn(true) + } + } } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/navigation/AppNavGraph.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/navigation/AppNavGraph.kt index c4e4e85d..4e16b578 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/navigation/AppNavGraph.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/navigation/AppNavGraph.kt @@ -46,6 +46,9 @@ sealed interface AppNavGraph { @Serializable data object BiometricEnrollDialog : AppNavGraph + @Serializable + data object BiometricDisableConfirmationDialog : AppNavGraph + @Serializable data class ErrorDialog(val message: String?) : AppNavGraph } diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt index bb40656a..c91876c4 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt @@ -13,6 +13,7 @@ import com.softartdev.notedelight.model.SettingsCategory import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.repository.SafeRepo +import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase @@ -36,6 +37,7 @@ class SettingsViewModel( private val snackbarInteractor: SnackbarInteractor, private val router: Router, private val revealFileListUseCase: RevealFileListUseCase, + private val disableBiometricUseCase: DisableBiometricUseCase, private val localeInteractor: LocaleInteractor, private val adaptiveInteractor: AdaptiveInteractor, private val biometricInteractor: BiometricInteractor, @@ -144,8 +146,18 @@ class SettingsViewModel( if (checked) { router.navigate(route = AppNavGraph.BiometricEnrollDialog) } else { - biometricInteractor.clearStoredPassword() - mutableStateFlow.update { it.copy(biometricEnabled = false) } + router.navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) + val disableBiometric: Boolean = withContext(coroutineDispatchers.io) { + DisableBiometricUseCase.dialogChannel.receive() + } + if (disableBiometric) { + withContext(coroutineDispatchers.io) { + disableBiometricUseCase() + } + mutableStateFlow.update { it.copy(biometricEnabled = false) } + } else { + logger.d { "Don't disable biometric" } + } } } catch (e: Throwable) { handleError(e) { "error toggling biometric" } diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt index daadbea9..f24cebff 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt @@ -31,9 +31,9 @@ inline fun retryUntilDisplayed( return sni.assertIsDisplayed() } -inline fun ComposeUiTest.waitUntilDisplayed( +fun ComposeUiTest.waitUntilDisplayed( description: String, - crossinline blockSNI: () -> SemanticsNodeInteraction, + blockSNI: () -> SemanticsNodeInteraction, ) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) { try { val sni = blockSNI() @@ -49,9 +49,9 @@ fun ComposeUiTest.waitUntilNotExist(tag: String) = waitUntilDoesNotExist( timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS, ) -inline fun ComposeUiTest.waitAssert( +fun ComposeUiTest.waitAssert( description: String, - crossinline assert: () -> Unit + assert: () -> Unit ) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) { try { assert() @@ -61,9 +61,9 @@ inline fun ComposeUiTest.waitAssert( return@waitUntil true } -inline fun ComposeUiTest.waitUntilSelected( +fun ComposeUiTest.waitUntilSelected( description: String, - crossinline blockSNI: () -> SemanticsNodeInteraction + blockSNI: () -> SemanticsNodeInteraction ) = waitUntil(conditionDescription = description, timeoutMillis = ASSERT_WAIT_TIMEOUT_MILLIS) { val sni = blockSNI().assertIsSelectable() try { @@ -74,4 +74,3 @@ inline fun ComposeUiTest.waitUntilSelected( return@waitUntil true } - diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt index 9ec95df2..acd12df8 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt @@ -8,6 +8,7 @@ import com.softartdev.notedelight.ui.screen.MainTestScreen import com.softartdev.notedelight.ui.screen.NoteScreen import com.softartdev.notedelight.ui.screen.SettingsTestScreen import com.softartdev.notedelight.ui.screen.SignInScreen +import com.softartdev.notedelight.ui.screen.dialog.BiometricDisableConfirmationDialog import com.softartdev.notedelight.ui.screen.dialog.ChangePasswordDialog import com.softartdev.notedelight.ui.screen.dialog.CommonDialog import com.softartdev.notedelight.ui.screen.dialog.CommonDialogImpl @@ -51,6 +52,10 @@ abstract class BaseTestCase(val composeUiTest: ComposeUiTest) { suspend inline fun changePasswordDialog(block: suspend ChangePasswordDialog.() -> Unit) = ChangePasswordDialog(commonDialog).block() + suspend inline fun biometricDisableConfirmationDialog( + block: suspend BiometricDisableConfirmationDialog.() -> Unit, + ) = BiometricDisableConfirmationDialog(commonDialog).block() + suspend inline fun languageDialog(block: suspend LanguageDialog.() -> Unit) = LanguageDialog(commonDialog).block() } diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt new file mode 100644 index 00000000..fd680c1a --- /dev/null +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricDisableConfirmationDialog.kt @@ -0,0 +1,19 @@ +package com.softartdev.notedelight.ui.screen.dialog + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.onNodeWithTag +import com.softartdev.notedelight.util.BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG +import com.softartdev.notedelight.util.CANCEL_BUTTON_TAG +import kotlin.jvm.JvmInline + +@JvmInline +value class BiometricDisableConfirmationDialog( + val commonDialog: CommonDialog, +) : CommonDialog by commonDialog { + + val dialogSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG) + + val cancelDialogButtonSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(CANCEL_BUTTON_TAG) +} diff --git a/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt b/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt new file mode 100644 index 00000000..cc21f0b3 --- /dev/null +++ b/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui.dialog.security + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import com.softartdev.notedelight.ui.screen.dialog.BiometricDisableConfirmationDialog as BiometricDisableConfirmationDialogScreen +import com.softartdev.notedelight.ui.screen.dialog.CommonDialogImpl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BiometricDisableConfirmationDialogTest { + + @Test + fun confirmClickCallsConfirm() = runComposeUiTest { + var confirmed = false + setContent { + BiometricDisableConfirmationDialog( + onConfirm = { confirmed = true }, + ) + } + + val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) + dialog.dialogSNI.assertIsDisplayed() + dialog.confirmDialogButtonSNI.performClick() + + assertTrue(confirmed) + } + + @Test + fun cancelClickCallsDismiss() = runComposeUiTest { + var dismissed = false + setContent { + BiometricDisableConfirmationDialog( + onDismiss = { dismissed = true }, + ) + } + + val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) + dialog.dialogSNI.assertIsDisplayed() + dialog.cancelDialogButtonSNI.performClick() + + assertTrue(dismissed) + } + + @Test + fun confirmClickDoesNotCallDismiss() = runComposeUiTest { + var dismissed = false + setContent { + BiometricDisableConfirmationDialog( + onDismiss = { dismissed = true }, + ) + } + + val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) + dialog.confirmDialogButtonSNI.performClick() + + assertFalse(dismissed) + } +} diff --git a/core/ui/src/commonMain/composeResources/values-ru/strings.xml b/core/ui/src/commonMain/composeResources/values-ru/strings.xml index 5f7666ab..a2b7e424 100644 --- a/core/ui/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-ru/strings.xml @@ -93,6 +93,9 @@ По биометрии Включить вход по биометрии Подтвердите пароль, чтобы разрешить вход по биометрии. + Отключить вход по биометрии? + Чтобы снова включить вход по биометрии, потребуется заново ввести пароль. + Отключить Вход по биометрии отключён — снова включите его в настройках. Не удалось пройти биометрию Биометрия недоступна diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index ee9cd067..3a7e639f 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -93,6 +93,9 @@ Use biometric Enable biometric sign-in Confirm your password to allow biometric unlock. + Disable biometric sign-in? + To enable biometric sign-in again, you will need to enter your password. + Disable Biometric sign-in disabled — please re-enable in Settings. Biometric authentication failed Biometric authentication is not available diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt index 287d861c..4fa91659 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/App.kt @@ -27,6 +27,7 @@ import com.softartdev.notedelight.ui.dialog.ErrorDialog import com.softartdev.notedelight.ui.dialog.LanguageDialog import com.softartdev.notedelight.ui.dialog.note.DeleteDialog import com.softartdev.notedelight.ui.dialog.note.SaveDialog +import com.softartdev.notedelight.ui.dialog.security.BiometricDisableConfirmationDialog import com.softartdev.notedelight.ui.dialog.security.BiometricEnrollDialog import com.softartdev.notedelight.ui.dialog.security.ChangePasswordDialog import com.softartdev.notedelight.ui.dialog.security.ConfirmPasswordDialog @@ -104,6 +105,9 @@ fun App( dialog { BiometricEnrollDialog(biometricEnrollViewModel = koinViewModel()) } + dialog { + BiometricDisableConfirmationDialog(biometricDisableViewModel = koinViewModel()) + } dialog { backStackEntry: NavBackStackEntry -> ErrorDialog( message = backStackEntry.toRoute().message, @@ -121,4 +125,4 @@ fun App( @Preview @Composable -fun PreviewApp() = PreviewKoin { App() } \ No newline at end of file +fun PreviewApp() = PreviewKoin { App() } diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt index c342184b..ead1dd8f 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt @@ -10,6 +10,7 @@ import com.softartdev.notedelight.presentation.settings.LanguageViewModel import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel import com.softartdev.notedelight.presentation.console.ConsoleViewModel import com.softartdev.notedelight.presentation.settings.SettingsViewModel +import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricDisableViewModel import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricEnrollViewModel import com.softartdev.notedelight.presentation.settings.security.change.ChangeViewModel import com.softartdev.notedelight.presentation.settings.security.confirm.ConfirmViewModel @@ -19,6 +20,7 @@ import com.softartdev.notedelight.presentation.splash.SplashViewModel import com.softartdev.notedelight.presentation.title.EditTitleViewModel import com.softartdev.notedelight.repository.FileRepo import com.softartdev.notedelight.repository.SafeRepo +import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.console.ConsoleUseCase import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase @@ -60,6 +62,7 @@ val useCaseModule: Module = module { factoryOf(::RevealFileListUseCase) factoryOf(::ExportDatabaseUseCase) factoryOf(::ImportDatabaseUseCase) + factoryOf(::DisableBiometricUseCase) factoryOfAppVersionUseCase() factoryOf(::ConsoleUseCase) } @@ -78,6 +81,7 @@ val viewModelModule: Module = module { viewModelOf(::ConfirmViewModel) viewModelOf(::ChangeViewModel) viewModelOf(::BiometricEnrollViewModel) + viewModelOf(::BiometricDisableViewModel) viewModelOf(::LanguageViewModel) viewModelOf(::FilesViewModel) viewModelOf(::ConsoleViewModel) diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialog.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialog.kt new file mode 100644 index 00000000..2c876a71 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialog.kt @@ -0,0 +1,64 @@ +package com.softartdev.notedelight.ui.dialog.security + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.softartdev.notedelight.presentation.settings.security.biometric.BiometricDisableViewModel +import com.softartdev.notedelight.ui.dialog.PreviewDialog +import com.softartdev.notedelight.util.BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG +import com.softartdev.notedelight.util.CANCEL_BUTTON_TAG +import com.softartdev.notedelight.util.YES_BUTTON_TAG +import notedelight.core.ui.generated.resources.Res +import notedelight.core.ui.generated.resources.biometric_disable_dialog_confirm +import notedelight.core.ui.generated.resources.biometric_disable_dialog_message +import notedelight.core.ui.generated.resources.biometric_disable_dialog_title +import notedelight.core.ui.generated.resources.cancel +import org.jetbrains.compose.resources.stringResource + +@Composable +fun BiometricDisableConfirmationDialog( + biometricDisableViewModel: BiometricDisableViewModel, +) = BiometricDisableConfirmationDialog( + onConfirm = biometricDisableViewModel::disableBiometricAndNavBack, + onDismiss = biometricDisableViewModel::doNotDisableBiometricAndNavBack, +) + +@Composable +fun BiometricDisableConfirmationDialog( + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {}, +) = AlertDialog( + modifier = Modifier.testTag(BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG), + title = { Text(stringResource(Res.string.biometric_disable_dialog_title)) }, + text = { Text(stringResource(Res.string.biometric_disable_dialog_message)) }, + confirmButton = { + Button( + modifier = Modifier.testTag(YES_BUTTON_TAG), + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text(stringResource(Res.string.biometric_disable_dialog_confirm)) } + }, + dismissButton = { + TextButton( + modifier = Modifier.testTag(CANCEL_BUTTON_TAG), + onClick = onDismiss, + ) { Text(stringResource(Res.string.cancel)) } + }, + onDismissRequest = onDismiss, +) + +@Preview +@Composable +fun PreviewBiometricDisableConfirmationDialog() = PreviewDialog { + BiometricDisableConfirmationDialog() +} diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt index d788a215..22611586 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/util/TestTags.kt @@ -83,6 +83,7 @@ const val BIOMETRIC_ENROLL_DIALOG_FIELD_TAG = "BIOMETRIC_ENROLL_DIALOG_FIELD_TAG const val BIOMETRIC_ENROLL_DIALOG_LABEL_TAG = "BIOMETRIC_ENROLL_DIALOG_LABEL_TAG" const val BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG = "BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG" const val BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG = "BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG" +const val BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG = "BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG" const val SET_PASSWORD_BUTTON_TAG = "SET_PASSWORD_BUTTON_TAG" const val LANGUAGE_BUTTON_TAG = "LANGUAGE_BUTTON_TAG" const val EXPORT_DATABASE_BUTTON_TAG = "EXPORT_DATABASE_BUTTON_TAG" diff --git a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt new file mode 100644 index 00000000..50dbb7f3 --- /dev/null +++ b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt @@ -0,0 +1,15 @@ +package com.softartdev.notedelight.usecase.biometric + +import com.softartdev.notedelight.interactor.BiometricInteractor +import kotlinx.coroutines.channels.Channel + +class DisableBiometricUseCase( + private val biometricInteractor: BiometricInteractor, +) : suspend () -> Unit { + + override suspend fun invoke() = biometricInteractor.clearStoredPassword() + + companion object { + val dialogChannel: Channel by lazy { return@lazy Channel() } + } +} diff --git a/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt new file mode 100644 index 00000000..1c07a161 --- /dev/null +++ b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt @@ -0,0 +1,29 @@ +package com.softartdev.notedelight.presentation.settings.security.biometric + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.softartdev.notedelight.navigation.Router +import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase +import com.softartdev.notedelight.util.CoroutineDispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class BiometricDisableViewModel( + private val router: Router, + private val coroutineDispatchers: CoroutineDispatchers, +) : ViewModel() { + + fun disableBiometricAndNavBack() = viewModelScope.launch { + withContext(coroutineDispatchers.io) { + DisableBiometricUseCase.dialogChannel.send(true) + } + router.popBackStack() + } + + fun doNotDisableBiometricAndNavBack() = viewModelScope.launch { + withContext(coroutineDispatchers.io) { + DisableBiometricUseCase.dialogChannel.send(false) + } + router.popBackStack() + } +} From 723b512ff5f96c48bbcb4396c7ee6fa7e5e27dbc Mon Sep 17 00:00:00 2001 From: Artur Babichev Date: Fri, 1 May 2026 02:10:51 +0400 Subject: [PATCH 5/5] refactor: biometric interactor and enhance UI test coverage - Convert `BiometricInteractor` from an `expect` class to an interface and provide platform-specific implementations (`AndroidBiometricInteractor`, `IosBiometricInteractor`, etc.). - Remove `DisableBiometricUseCase` and move its functionality and the biometric disable dialog channel into `BiometricInteractor`. - Implement `TestBiometricInteractor` and a corresponding Koin module to support mocked biometric states during UI tests. - Add `BiometricSignInTestCase` and `BiometricSettingsTestCase` to provide reusable test logic for biometric flows. - Introduce `BiometricEnrollDialog` semantics wrapper and update `SettingsTestScreen` and `SignInScreen` with new biometric test tags. - Update `SettingsViewModel` and dependency injection modules to reflect the new `BiometricInteractor` structure and the removal of the use case. - Add Android instrumentation tests (`BiometricSignInTest`, `BiometricSettingsTest`) to verify biometric integration on the platform. --- .../notedelight/ui/BiometricSettingsTest.kt | 54 ++++++++++++ .../notedelight/ui/BiometricSignInTest.kt | 55 ++++++++++++ .../adaptive/AdaptiveInteractorTest.kt | 2 - .../settings/SettingsViewModelTest.kt | 6 +- .../settings/SettingsViewModel.kt | 6 +- core/test/ui/build.gradle.kts | 3 +- .../notedelight/di/uiTestModules.kt | 9 +- .../kotlin/com/softartdev/notedelight/ext.kt | 3 +- .../interactor/TestBiometricInteractor.kt | 54 ++++++++++++ .../softartdev/notedelight/ui/BaseTestCase.kt | 4 + .../ui/cases/BiometricSettingsTestCase.kt | 87 +++++++++++++++++++ .../ui/cases/BiometricSignInTestCase.kt | 35 ++++++++ .../ui/screen/SettingsTestScreen.kt | 7 ++ .../notedelight/ui/screen/SignInScreen.kt | 8 +- .../ui/screen/dialog/BiometricEnrollDialog.kt | 31 +++++++ .../BiometricDisableConfirmationDialogTest.kt | 63 -------------- .../notedelight/di/uiModules.android.kt | 5 +- .../notedelight/di/sharedModules.kt | 2 - .../notedelight/di/uiModules.ios.kt | 3 +- .../notedelight/di/uiModules.jvm.kt | 3 +- .../notedelight/di/uiModules.wasmJs.kt | 3 +- ...droid.kt => AndroidBiometricInteractor.kt} | 12 +-- .../interactor/BiometricInteractor.kt | 8 +- .../biometric/DisableBiometricUseCase.kt | 15 ---- ...actor.ios.kt => IosBiometricInteractor.kt} | 12 +-- ...actor.jvm.kt => JvmBiometricInteractor.kt} | 13 +-- ...or.wasmJs.kt => WebBiometricInteractor.kt} | 13 +-- .../biometric/BiometricDisableViewModel.kt | 6 +- 28 files changed, 394 insertions(+), 128 deletions(-) create mode 100644 app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSettingsTest.kt create mode 100644 app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSignInTest.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/TestBiometricInteractor.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSettingsTestCase.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSignInTestCase.kt create mode 100644 core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricEnrollDialog.kt delete mode 100644 core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt rename feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.android.kt => AndroidBiometricInteractor.kt} (96%) delete mode 100644 feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt rename feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.ios.kt => IosBiometricInteractor.kt} (96%) rename feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.jvm.kt => JvmBiometricInteractor.kt} (59%) rename feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/{BiometricInteractor.wasmJs.kt => WebBiometricInteractor.kt} (59%) diff --git a/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSettingsTest.kt b/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSettingsTest.kt new file mode 100644 index 00000000..ebaee9c6 --- /dev/null +++ b/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSettingsTest.kt @@ -0,0 +1,54 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.FlakyTest +import com.softartdev.notedelight.MainActivity +import com.softartdev.notedelight.di.biometricTestModule +import com.softartdev.notedelight.interactor.TestBiometricInteractor +import com.softartdev.notedelight.reflect +import com.softartdev.notedelight.ui.cases.BiometricSettingsTestCase +import leakcanary.DetectLeaksAfterTestSuccess +import leakcanary.TestDescriptionHolder +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.koin.core.context.loadKoinModules +import org.koin.mp.KoinPlatformTools + +@FlakyTest +@RunWith(AndroidJUnit4::class) +class BiometricSettingsTest { + + private val testBiometricInteractor: TestBiometricInteractor + get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class) + + private val composeTestRule = customAndroidComposeRule( + beforeActivityLaunched = { + loadKoinModules(biometricTestModule) + testBiometricInteractor.reset(canAuthenticateResult = true) + } + ) + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder) + .around(DetectLeaksAfterTestSuccess()) + .around(composeTestRule) + + private val composeUiTest: ComposeUiTest = reflect(composeTestRule) + + @After + fun tearDown() = testBiometricInteractor.reset() + + @Test + fun biometricSettingsTest() = BiometricSettingsTestCase( + composeUiTest = composeUiTest, + closeSoftKeyboard = Espresso::closeSoftKeyboard, + ).invoke() +} diff --git a/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSignInTest.kt b/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSignInTest.kt new file mode 100644 index 00000000..6342cb90 --- /dev/null +++ b/app/android/src/androidTest/java/com/softartdev/notedelight/ui/BiometricSignInTest.kt @@ -0,0 +1,55 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.FlakyTest +import com.softartdev.notedelight.DbTestEncryptor +import com.softartdev.notedelight.MainActivity +import com.softartdev.notedelight.di.biometricTestModule +import com.softartdev.notedelight.interactor.TestBiometricInteractor +import com.softartdev.notedelight.reflect +import com.softartdev.notedelight.ui.cases.BiometricSignInTestCase +import leakcanary.DetectLeaksAfterTestSuccess +import leakcanary.TestDescriptionHolder +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.koin.core.context.loadKoinModules +import org.koin.mp.KoinPlatformTools + +@FlakyTest +@RunWith(AndroidJUnit4::class) +class BiometricSignInTest { + + private val testBiometricInteractor: TestBiometricInteractor + get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class) + + private val composeTestRule = customAndroidComposeRule( + beforeActivityLaunched = { + loadKoinModules(biometricTestModule) + testBiometricInteractor.reset( + canAuthenticateResult = true, + storedPassword = DbTestEncryptor.PASSWORD, + ) + DbTestEncryptor() + } + ) + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder) + .around(DetectLeaksAfterTestSuccess()) + .around(composeTestRule) + + private val composeUiTest: ComposeUiTest = reflect(composeTestRule) + + @After + fun tearDown() = testBiometricInteractor.reset() + + @Test + fun biometricSignInTest() = BiometricSignInTestCase(composeUiTest = composeUiTest).invoke() +} diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt index d1479e8d..c69d740c 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt @@ -27,7 +27,6 @@ import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel import com.softartdev.notedelight.presentation.settings.SettingsViewModel import com.softartdev.notedelight.repository.SafeRepo -import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.note.CreateNoteUseCase import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase @@ -119,7 +118,6 @@ class AdaptiveInteractorTest { snackbarInteractor = mockSnackbarInteractor, router = mockRouter, revealFileListUseCase = revealFileListUseCase, - disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, biometricInteractor = mockBiometricInteractor, diff --git a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt index eedf3f4f..7b7d3310 100644 --- a/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt +++ b/core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt @@ -16,7 +16,6 @@ import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.presentation.MainDispatcherRule import com.softartdev.notedelight.repository.SafeRepo -import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase @@ -61,7 +60,6 @@ class SettingsViewModelTest { snackbarInteractor = mockSnackbarInteractor, router = mockRouter, revealFileListUseCase = RevealFileListUseCase(), - disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor), localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, biometricInteractor = mockBiometricInteractor, @@ -184,7 +182,7 @@ class SettingsViewModelTest { settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) - DisableBiometricUseCase.dialogChannel.send(false) + BiometricInteractor.disableDialogChannel.send(false) mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) @@ -203,7 +201,7 @@ class SettingsViewModelTest { settingsViewModel.onAction(SettingsAction.ChangeBiometric(false)) Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) - DisableBiometricUseCase.dialogChannel.send(true) + BiometricInteractor.disableDialogChannel.send(true) mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt index c91876c4..2f0fd260 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt @@ -13,7 +13,6 @@ import com.softartdev.notedelight.model.SettingsCategory import com.softartdev.notedelight.navigation.AppNavGraph import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.repository.SafeRepo -import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase import com.softartdev.notedelight.usecase.settings.AppVersionUseCase import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase @@ -37,7 +36,6 @@ class SettingsViewModel( private val snackbarInteractor: SnackbarInteractor, private val router: Router, private val revealFileListUseCase: RevealFileListUseCase, - private val disableBiometricUseCase: DisableBiometricUseCase, private val localeInteractor: LocaleInteractor, private val adaptiveInteractor: AdaptiveInteractor, private val biometricInteractor: BiometricInteractor, @@ -148,11 +146,11 @@ class SettingsViewModel( } else { router.navigate(route = AppNavGraph.BiometricDisableConfirmationDialog) val disableBiometric: Boolean = withContext(coroutineDispatchers.io) { - DisableBiometricUseCase.dialogChannel.receive() + BiometricInteractor.disableDialogChannel.receive() } if (disableBiometric) { withContext(coroutineDispatchers.io) { - disableBiometricUseCase() + biometricInteractor.clearStoredPassword() } mutableStateFlow.update { it.copy(biometricEnabled = false) } } else { diff --git a/core/test/ui/build.gradle.kts b/core/test/ui/build.gradle.kts index 3cbf8b48..091427f0 100644 --- a/core/test/ui/build.gradle.kts +++ b/core/test/ui/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(projects.core.presentation) implementation(projects.core.ui) implementation(projects.feature.backup.ui) + api(projects.feature.biometric.domain) implementation(projects.feature.console.presentation) implementation(projects.feature.console.ui) implementation(libs.compose.ui.test) @@ -95,4 +96,4 @@ kotlin { compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } -project.disableIosReleaseTasks() \ No newline at end of file +project.disableIosReleaseTasks() diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/uiTestModules.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/uiTestModules.kt index 10e0ec6b..149732d2 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/uiTestModules.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/uiTestModules.kt @@ -1,6 +1,8 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.UiThreadRouter +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.TestBiometricInteractor import com.softartdev.notedelight.navigation.Router import com.softartdev.notedelight.navigation.RouterImpl import com.softartdev.notedelight.ui.settings.detail.DatabaseFilePicker @@ -9,7 +11,7 @@ import org.koin.core.module.Module import org.koin.dsl.module val uiTestModules: List - get() = listOf(navigationTestModule, interactorModule, utilModule, backupTestModule) + get() = listOf(navigationTestModule, interactorModule, utilModule, backupTestModule, biometricTestModule) val navigationTestModule = module { single { UiThreadRouter(router = RouterImpl()) } @@ -18,3 +20,8 @@ val navigationTestModule = module { val backupTestModule = module { single { TestDatabaseFilePicker() } } + +val biometricTestModule = module { + single { TestBiometricInteractor() } + single { get() } +} diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt index f24cebff..bb038e50 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt @@ -15,7 +15,7 @@ import co.touchlab.kermit.Logger const val ASSERT_WAIT_TIMEOUT_MILLIS: Long = 20_000 const val MAX_RETRY_ATTEMPTS = 100 -inline fun retryUntilDisplayed( +fun retryUntilDisplayed( description: String, action: () -> Unit, sni: SemanticsNodeInteraction, @@ -73,4 +73,3 @@ fun ComposeUiTest.waitUntilSelected( } return@waitUntil true } - diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/TestBiometricInteractor.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/TestBiometricInteractor.kt new file mode 100644 index 00000000..c8b50f12 --- /dev/null +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/interactor/TestBiometricInteractor.kt @@ -0,0 +1,54 @@ +package com.softartdev.notedelight.interactor + +class TestBiometricInteractor : BiometricInteractor { + var canAuthenticateResult: Boolean = false + private set + var storedPassword: CharSequence? = null + private set + var encryptResult: BiometricResult = BiometricResult.Success + var decryptResult: DecryptedPasswordResult? = null + var clearStoredPasswordCount: Int = 0 + private set + + fun reset( + canAuthenticateResult: Boolean = false, + storedPassword: CharSequence? = null, + ) { + this.canAuthenticateResult = canAuthenticateResult + this.storedPassword = storedPassword + encryptResult = BiometricResult.Success + decryptResult = null + clearStoredPasswordCount = 0 + } + + override suspend fun canAuthenticate(): Boolean = canAuthenticateResult + + override suspend fun hasStoredPassword(): Boolean = storedPassword != null + + override suspend fun encryptAndStorePassword( + password: CharSequence, + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): BiometricResult { + if (encryptResult == BiometricResult.Success) { + storedPassword = password.toString() + } + return encryptResult + } + + override suspend fun decryptStoredPassword( + title: String, + subtitle: String, + negativeButton: String, + biometricPlatformWrapper: BiometricPlatformWrapper, + ): DecryptedPasswordResult = decryptResult + ?: storedPassword?.let { DecryptedPasswordResult.Success(it) } + ?: DecryptedPasswordResult.Unavailable + + override suspend fun clearStoredPassword() { + clearStoredPasswordCount++ + storedPassword = null + } +} diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt index acd12df8..41e1949b 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt @@ -9,6 +9,7 @@ import com.softartdev.notedelight.ui.screen.NoteScreen import com.softartdev.notedelight.ui.screen.SettingsTestScreen import com.softartdev.notedelight.ui.screen.SignInScreen import com.softartdev.notedelight.ui.screen.dialog.BiometricDisableConfirmationDialog +import com.softartdev.notedelight.ui.screen.dialog.BiometricEnrollDialog import com.softartdev.notedelight.ui.screen.dialog.ChangePasswordDialog import com.softartdev.notedelight.ui.screen.dialog.CommonDialog import com.softartdev.notedelight.ui.screen.dialog.CommonDialogImpl @@ -56,6 +57,9 @@ abstract class BaseTestCase(val composeUiTest: ComposeUiTest) { block: suspend BiometricDisableConfirmationDialog.() -> Unit, ) = BiometricDisableConfirmationDialog(commonDialog).block() + suspend inline fun biometricEnrollDialog(block: suspend BiometricEnrollDialog.() -> Unit) = + BiometricEnrollDialog(commonDialog).block() + suspend inline fun languageDialog(block: suspend LanguageDialog.() -> Unit) = LanguageDialog(commonDialog).block() } diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSettingsTestCase.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSettingsTestCase.kt new file mode 100644 index 00000000..29b1b5af --- /dev/null +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSettingsTestCase.kt @@ -0,0 +1,87 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui.cases + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +import com.softartdev.notedelight.interactor.BiometricInteractor +import com.softartdev.notedelight.interactor.TestBiometricInteractor +import com.softartdev.notedelight.ui.BaseTestCase +import com.softartdev.notedelight.util.BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_TAG +import com.softartdev.notedelight.util.CONFIRM_PASSWORD_DIALOG_TAG +import com.softartdev.notedelight.waitAssert +import com.softartdev.notedelight.waitUntilDisplayed +import com.softartdev.notedelight.waitUntilNotExist +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.koin.mp.KoinPlatformTools +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.minutes + +class BiometricSettingsTestCase( + composeUiTest: ComposeUiTest, + private val closeSoftKeyboard: () -> Unit, +) : () -> TestResult, BaseTestCase(composeUiTest) { + + override fun invoke() = runTest(timeout = 3.minutes) { + val biometricInteractor: TestBiometricInteractor = + KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class) + biometricInteractor.reset(canAuthenticateResult = true) + val password = "biometric-password" + + mainTestScreen { + composeUiTest.waitUntilDisplayed("settingsMenuButton", blockSNI = ::settingsMenuButtonSNI) + settingsMenuButtonSNI.performClick() + } + settingsTestScreen { + composeUiTest.waitUntilDisplayed("securityCategory", blockSNI = ::securityCategorySNI) + securityCategorySNI.performClick() + composeUiTest.waitUntilDisplayed("encryptionSwitch", blockSNI = ::encryptionSwitchSNI) + encryptionSwitchSNI.assertIsOff().performClick() + } + confirmPasswordDialog { + composeUiTest.waitUntilDisplayed("confirmPasswordDialog", blockSNI = ::dialogSNI) + confirmPasswordSNI.performTextReplacement(password) + closeSoftKeyboard() + confirmRepeatPasswordSNI.performTextReplacement(password) + closeSoftKeyboard() + confirmDialogButtonSNI.performSemanticsAction(SemanticsActions.OnClick) + } + composeUiTest.waitUntilNotExist(CONFIRM_PASSWORD_DIALOG_TAG) + settingsTestScreen { + composeUiTest.waitAssert("encryption switch is ON", encryptionSwitchSNI::assertIsOn) + composeUiTest.waitUntilDisplayed("biometricSwitch", blockSNI = ::biometricSwitchSNI) + biometricSwitchSNI.assertIsOff().performClick() + } + biometricEnrollDialog { + composeUiTest.waitUntilDisplayed("biometricEnrollDialog", blockSNI = ::dialogSNI) + passwordSNI.performTextReplacement(password) + closeSoftKeyboard() + confirmDialogButtonSNI.performSemanticsAction(SemanticsActions.OnClick) + } + composeUiTest.waitUntilNotExist(BIOMETRIC_ENROLL_DIALOG_TAG) + settingsTestScreen { + composeUiTest.waitAssert("biometric switch is ON", biometricSwitchSNI::assertIsOn) + biometricSwitchSNI.performClick() + } + biometricDisableConfirmationDialog { + composeUiTest.waitUntilDisplayed("biometricDisableConfirmationDialog", blockSNI = ::dialogSNI) + confirmDialogButtonSNI.performSemanticsAction(SemanticsActions.OnClick) + } + composeUiTest.waitUntilNotExist(BIOMETRIC_DISABLE_CONFIRMATION_DIALOG_TAG) + settingsTestScreen { + composeUiTest.waitAssert("biometric switch is OFF", biometricSwitchSNI::assertIsOff) + } + assertNull(biometricInteractor.storedPassword) + assertEquals(1, biometricInteractor.clearStoredPasswordCount) + BiometricInteractor.disableDialogChannel.tryReceive() + } +} diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSignInTestCase.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSignInTestCase.kt new file mode 100644 index 00000000..f1d1545b --- /dev/null +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/cases/BiometricSignInTestCase.kt @@ -0,0 +1,35 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui.cases + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.performClick +import com.softartdev.notedelight.DbTestEncryptor +import com.softartdev.notedelight.interactor.TestBiometricInteractor +import com.softartdev.notedelight.ui.BaseTestCase +import com.softartdev.notedelight.waitUntilDisplayed +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.koin.mp.KoinPlatformTools + +class BiometricSignInTestCase( + composeUiTest: ComposeUiTest, +) : () -> TestResult, BaseTestCase(composeUiTest) { + + override fun invoke() = runTest { + val biometricInteractor: TestBiometricInteractor = + KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class) + biometricInteractor.reset( + canAuthenticateResult = true, + storedPassword = DbTestEncryptor.PASSWORD, + ) + signInScreen { + composeUiTest.waitUntilDisplayed("biometricButton", blockSNI = ::biometricButtonSNI) + biometricButtonSNI.performClick() + } + mainTestScreen { + composeUiTest.waitUntilDisplayed("emptyResultLabel", blockSNI = ::emptyResultLabelSNI) + } + } +} diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SettingsTestScreen.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SettingsTestScreen.kt index e33e6e38..8f2f5cf6 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SettingsTestScreen.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SettingsTestScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsToggleable import androidx.compose.ui.test.onNodeWithTag +import com.softartdev.notedelight.util.ENABLE_BIOMETRIC_SWITCH_TAG import com.softartdev.notedelight.util.ENABLE_ENCRYPTION_SWITCH_TAG import com.softartdev.notedelight.util.EXPORT_DATABASE_BUTTON_TAG import com.softartdev.notedelight.util.IMPORT_DATABASE_BUTTON_TAG @@ -51,6 +52,12 @@ value class SettingsTestScreen(val nodeProvider: SemanticsNodeInteractionsProvid .assertIsToggleable() .assertIsDisplayed() + val biometricSwitchSNI: SemanticsNodeInteraction + get() = nodeProvider + .onNodeWithTag(ENABLE_BIOMETRIC_SWITCH_TAG) + .assertIsToggleable() + .assertIsDisplayed() + val setPasswordSNI: SemanticsNodeInteraction get() = nodeProvider .onNodeWithTag(SET_PASSWORD_BUTTON_TAG) diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SignInScreen.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SignInScreen.kt index 582f82b2..a69bd182 100644 --- a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SignInScreen.kt +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/SignInScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithTag +import com.softartdev.notedelight.util.SIGN_IN_BIOMETRIC_BUTTON_TAG import com.softartdev.notedelight.util.SIGN_IN_BUTTON_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_FIELD_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_LABEL_TAG @@ -32,4 +33,9 @@ value class SignInScreen(val nodeProvider: SemanticsNodeInteractionsProvider) { get() = nodeProvider .onNodeWithTag(SIGN_IN_BUTTON_TAG) .assertIsDisplayed() -} \ No newline at end of file + + val biometricButtonSNI: SemanticsNodeInteraction + get() = nodeProvider + .onNodeWithTag(SIGN_IN_BIOMETRIC_BUTTON_TAG) + .assertIsDisplayed() +} diff --git a/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricEnrollDialog.kt b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricEnrollDialog.kt new file mode 100644 index 00000000..d0d79081 --- /dev/null +++ b/core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/screen/dialog/BiometricEnrollDialog.kt @@ -0,0 +1,31 @@ +package com.softartdev.notedelight.ui.screen.dialog + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.onNodeWithTag +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_FIELD_TAG +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_LABEL_TAG +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_TAG +import com.softartdev.notedelight.util.BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG +import kotlin.jvm.JvmInline + +@JvmInline +value class BiometricEnrollDialog( + val commonDialog: CommonDialog, +) : CommonDialog by commonDialog { + + val dialogSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_ENROLL_DIALOG_TAG, useUnmergedTree = true) + + val passwordSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_ENROLL_DIALOG_FIELD_TAG, useUnmergedTree = true) + + val labelSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_ENROLL_DIALOG_LABEL_TAG, useUnmergedTree = true) + + val visibilitySNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_ENROLL_DIALOG_VISIBILITY_TAG, useUnmergedTree = true) + + override val confirmDialogButtonSNI: SemanticsNodeInteraction + get() = nodeProvider.onNodeWithTag(BIOMETRIC_ENROLL_DIALOG_SAVE_BUTTON_TAG, useUnmergedTree = true) +} diff --git a/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt b/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt deleted file mode 100644 index cc21f0b3..00000000 --- a/core/test/ui/src/commonTest/kotlin/com/softartdev/notedelight/ui/dialog/security/BiometricDisableConfirmationDialogTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -@file:OptIn(ExperimentalTestApi::class) - -package com.softartdev.notedelight.ui.dialog.security - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest -import com.softartdev.notedelight.ui.screen.dialog.BiometricDisableConfirmationDialog as BiometricDisableConfirmationDialogScreen -import com.softartdev.notedelight.ui.screen.dialog.CommonDialogImpl -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class BiometricDisableConfirmationDialogTest { - - @Test - fun confirmClickCallsConfirm() = runComposeUiTest { - var confirmed = false - setContent { - BiometricDisableConfirmationDialog( - onConfirm = { confirmed = true }, - ) - } - - val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) - dialog.dialogSNI.assertIsDisplayed() - dialog.confirmDialogButtonSNI.performClick() - - assertTrue(confirmed) - } - - @Test - fun cancelClickCallsDismiss() = runComposeUiTest { - var dismissed = false - setContent { - BiometricDisableConfirmationDialog( - onDismiss = { dismissed = true }, - ) - } - - val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) - dialog.dialogSNI.assertIsDisplayed() - dialog.cancelDialogButtonSNI.performClick() - - assertTrue(dismissed) - } - - @Test - fun confirmClickDoesNotCallDismiss() = runComposeUiTest { - var dismissed = false - setContent { - BiometricDisableConfirmationDialog( - onDismiss = { dismissed = true }, - ) - } - - val dialog = BiometricDisableConfirmationDialogScreen(CommonDialogImpl(this)) - dialog.confirmDialogButtonSNI.performClick() - - assertFalse(dismissed) - } -} diff --git a/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt b/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt index d858274e..251fb996 100644 --- a/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt +++ b/core/ui/src/androidMain/kotlin/com/softartdev/notedelight/di/uiModules.android.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor +import com.softartdev.notedelight.interactor.AndroidBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) -} \ No newline at end of file + single { AndroidBiometricInteractor(get()) } +} diff --git a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt index ead1dd8f..e71de805 100644 --- a/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt +++ b/core/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/sharedModules.kt @@ -20,7 +20,6 @@ import com.softartdev.notedelight.presentation.splash.SplashViewModel import com.softartdev.notedelight.presentation.title.EditTitleViewModel import com.softartdev.notedelight.repository.FileRepo import com.softartdev.notedelight.repository.SafeRepo -import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.usecase.console.ConsoleUseCase import com.softartdev.notedelight.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.usecase.crypt.CheckPasswordUseCase @@ -62,7 +61,6 @@ val useCaseModule: Module = module { factoryOf(::RevealFileListUseCase) factoryOf(::ExportDatabaseUseCase) factoryOf(::ImportDatabaseUseCase) - factoryOf(::DisableBiometricUseCase) factoryOfAppVersionUseCase() factoryOf(::ConsoleUseCase) } diff --git a/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/di/uiModules.ios.kt b/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/di/uiModules.ios.kt index eb581896..e86895e7 100644 --- a/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/di/uiModules.ios.kt +++ b/core/ui/src/iosMain/kotlin/com/softartdev/notedelight/di/uiModules.ios.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor +import com.softartdev.notedelight.interactor.IosBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::IosBiometricInteractor) } diff --git a/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/di/uiModules.jvm.kt b/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/di/uiModules.jvm.kt index eb581896..f02e75ed 100644 --- a/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/di/uiModules.jvm.kt +++ b/core/ui/src/jvmMain/kotlin/com/softartdev/notedelight/di/uiModules.jvm.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor +import com.softartdev.notedelight.interactor.JvmBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::JvmBiometricInteractor) } diff --git a/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/di/uiModules.wasmJs.kt b/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/di/uiModules.wasmJs.kt index eb581896..148fac91 100644 --- a/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/di/uiModules.wasmJs.kt +++ b/core/ui/src/wasmJsMain/kotlin/com/softartdev/notedelight/di/uiModules.wasmJs.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.di import com.softartdev.notedelight.interactor.AdaptiveInteractor import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.interactor.LocaleInteractor +import com.softartdev.notedelight.interactor.WebBiometricInteractor import com.softartdev.notedelight.interactor.SnackbarInteractor import com.softartdev.notedelight.interactor.SnackbarInteractorImpl import org.koin.core.module.Module @@ -13,5 +14,5 @@ actual val interactorModule: Module = module { singleOf(::AdaptiveInteractor) singleOf(::SnackbarInteractorImpl) singleOf(::LocaleInteractor) - singleOf(::BiometricInteractor) + singleOf(::WebBiometricInteractor) } diff --git a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt similarity index 96% rename from feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt rename to feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt index 6885719a..f1cb60cc 100644 --- a/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.android.kt +++ b/feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt @@ -22,18 +22,18 @@ import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import kotlin.coroutines.resume -actual class BiometricInteractor(context: Context) { +class AndroidBiometricInteractor(context: Context) : BiometricInteractor { private val logger = Logger.withTag("BiometricInteractor") private val appContext: Context = context.applicationContext private val credentialsStore = BiometricCredentialsStore(appContext) - actual suspend fun canAuthenticate(): Boolean = BiometricManager + override suspend fun canAuthenticate(): Boolean = BiometricManager .from(appContext) .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - actual suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() + override suspend fun hasStoredPassword(): Boolean = credentialsStore.hasCredentials() - actual suspend fun encryptAndStorePassword( + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -64,7 +64,7 @@ actual class BiometricInteractor(context: Context) { } } - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, @@ -114,7 +114,7 @@ actual class BiometricInteractor(context: Context) { } } - actual suspend fun clearStoredPassword() { + override suspend fun clearStoredPassword() { credentialsStore.clear() runCatching { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS) diff --git a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt index a4b9ec15..0699c0e5 100644 --- a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt +++ b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.kt @@ -1,6 +1,8 @@ package com.softartdev.notedelight.interactor -expect class BiometricInteractor { +import kotlinx.coroutines.channels.Channel + +interface BiometricInteractor { suspend fun canAuthenticate(): Boolean @@ -22,4 +24,8 @@ expect class BiometricInteractor { ): DecryptedPasswordResult suspend fun clearStoredPassword() + + companion object { + val disableDialogChannel: Channel by lazy { Channel() } + } } diff --git a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt b/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt deleted file mode 100644 index 50dbb7f3..00000000 --- a/feature/biometric/domain/src/commonMain/kotlin/com/softartdev/notedelight/usecase/biometric/DisableBiometricUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.softartdev.notedelight.usecase.biometric - -import com.softartdev.notedelight.interactor.BiometricInteractor -import kotlinx.coroutines.channels.Channel - -class DisableBiometricUseCase( - private val biometricInteractor: BiometricInteractor, -) : suspend () -> Unit { - - override suspend fun invoke() = biometricInteractor.clearStoredPassword() - - companion object { - val dialogChannel: Channel by lazy { return@lazy Channel() } - } -} diff --git a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt similarity index 96% rename from feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt rename to feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt index e5a59934..fb310329 100644 --- a/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.ios.kt +++ b/feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt @@ -59,12 +59,12 @@ import platform.Security.kSecValueData import platform.darwin.OSStatus import kotlin.coroutines.resume -actual class BiometricInteractor { +class IosBiometricInteractor : BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = LAContext() + override suspend fun canAuthenticate(): Boolean = LAContext() .canEvaluatePolicy(LAPolicyDeviceOwnerAuthenticationWithBiometrics, null) - actual suspend fun hasStoredPassword(): Boolean = memScoped { + override suspend fun hasStoredPassword(): Boolean = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() @@ -82,7 +82,7 @@ actual class BiometricInteractor { } } - actual suspend fun encryptAndStorePassword( + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -132,7 +132,7 @@ actual class BiometricInteractor { } } - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, @@ -191,7 +191,7 @@ actual class BiometricInteractor { } } - actual suspend fun clearStoredPassword(): Unit = memScoped { + override suspend fun clearStoredPassword(): Unit = memScoped { val service: CFTypeRef? = CFBridgingRetain(SERVICE) val account: CFTypeRef? = CFBridgingRetain(ACCOUNT) val query: CFMutableDictionaryRef? = newMutableDict() diff --git a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt similarity index 59% rename from feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt rename to feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt index 4fc40765..c5887ce0 100644 --- a/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.jvm.kt +++ b/feature/biometric/domain/src/jvmMain/kotlin/com/softartdev/notedelight/interactor/JvmBiometricInteractor.kt @@ -1,11 +1,12 @@ package com.softartdev.notedelight.interactor -actual class BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = false +class JvmBiometricInteractor : BiometricInteractor { - actual suspend fun hasStoredPassword(): Boolean = false + override suspend fun canAuthenticate(): Boolean = false - actual suspend fun encryptAndStorePassword( + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -13,12 +14,12 @@ actual class BiometricInteractor { biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable - actual suspend fun clearStoredPassword() = Unit + override suspend fun clearStoredPassword() = Unit } diff --git a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt similarity index 59% rename from feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt rename to feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt index 4fc40765..73ff4907 100644 --- a/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/BiometricInteractor.wasmJs.kt +++ b/feature/biometric/domain/src/wasmJsMain/kotlin/com/softartdev/notedelight/interactor/WebBiometricInteractor.kt @@ -1,11 +1,12 @@ package com.softartdev.notedelight.interactor -actual class BiometricInteractor { - actual suspend fun canAuthenticate(): Boolean = false +class WebBiometricInteractor : BiometricInteractor { - actual suspend fun hasStoredPassword(): Boolean = false + override suspend fun canAuthenticate(): Boolean = false - actual suspend fun encryptAndStorePassword( + override suspend fun hasStoredPassword(): Boolean = false + + override suspend fun encryptAndStorePassword( password: CharSequence, title: String, subtitle: String, @@ -13,12 +14,12 @@ actual class BiometricInteractor { biometricPlatformWrapper: BiometricPlatformWrapper, ): BiometricResult = BiometricResult.Unavailable - actual suspend fun decryptStoredPassword( + override suspend fun decryptStoredPassword( title: String, subtitle: String, negativeButton: String, biometricPlatformWrapper: BiometricPlatformWrapper, ): DecryptedPasswordResult = DecryptedPasswordResult.Unavailable - actual suspend fun clearStoredPassword() = Unit + override suspend fun clearStoredPassword() = Unit } diff --git a/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt index 1c07a161..81b08223 100644 --- a/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt +++ b/feature/biometric/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/security/biometric/BiometricDisableViewModel.kt @@ -2,8 +2,8 @@ package com.softartdev.notedelight.presentation.settings.security.biometric import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.softartdev.notedelight.interactor.BiometricInteractor import com.softartdev.notedelight.navigation.Router -import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase import com.softartdev.notedelight.util.CoroutineDispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -15,14 +15,14 @@ class BiometricDisableViewModel( fun disableBiometricAndNavBack() = viewModelScope.launch { withContext(coroutineDispatchers.io) { - DisableBiometricUseCase.dialogChannel.send(true) + BiometricInteractor.disableDialogChannel.send(true) } router.popBackStack() } fun doNotDisableBiometricAndNavBack() = viewModelScope.launch { withContext(coroutineDispatchers.io) { - DisableBiometricUseCase.dialogChannel.send(false) + BiometricInteractor.disableDialogChannel.send(false) } router.popBackStack() }