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 93994175e..082ba0e75 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 @@ -45,6 +45,7 @@ class SettingsViewModelTest { private val mockSnackbarInteractor = Mockito.mock(SnackbarInteractor::class.java) private val mockLocaleInteractor = Mockito.mock(LocaleInteractor::class.java) private val mockAppVersionUseCase = Mockito.mock(AppVersionUseCase::class.java) + private val mockBiometricSettingsGateway = Mockito.mock(BiometricSettingsGateway::class.java) private val adaptiveInteractor = AdaptiveInteractor() private val coroutineDispatchers = CoroutineDispatchersStub(mainDispatcherRule.testDispatcher.scheduler) private val settingsViewModel = SettingsViewModel( @@ -59,6 +60,7 @@ class SettingsViewModelTest { localeInteractor = mockLocaleInteractor, adaptiveInteractor = adaptiveInteractor, coroutineDispatchers = coroutineDispatchers, + biometricSettingsGateway = mockBiometricSettingsGateway, ) @After @@ -83,6 +85,8 @@ class SettingsViewModelTest { fun refreshUpdatesSwitches() = runTest { Mockito.`when`(mockSafeRepo.databaseState).thenReturn(ENCRYPTED) Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + Mockito.`when`(mockBiometricSettingsGateway.isSupported()).thenReturn(true) + Mockito.`when`(mockBiometricSettingsGateway.isEnabled()).thenReturn(true) settingsViewModel.stateFlow.test { assertFalse(awaitItem().loading) settingsViewModel.onAction(SettingsAction.Refresh) @@ -91,6 +95,8 @@ class SettingsViewModelTest { result = awaitItem() } assertTrue(result.encryption) + assertTrue(result.biometricSupported) + assertTrue(result.biometricEnabled) cancelAndIgnoreRemainingEvents() } Mockito.verifyNoMoreInteractions(mockRouter) @@ -100,6 +106,7 @@ class SettingsViewModelTest { val platformSQLiteState = if (encryption) ENCRYPTED else UNENCRYPTED Mockito.`when`(mockSafeRepo.databaseState).thenReturn(platformSQLiteState) Mockito.`when`(mockLocaleInteractor.languageEnum).thenReturn(LanguageEnum.ENGLISH) + Mockito.`when`(mockBiometricSettingsGateway.isSupported()).thenReturn(false) settingsViewModel.stateFlow.test { assertFalse(awaitItem().loading) settingsViewModel.updateSwitches() @@ -250,4 +257,35 @@ class SettingsViewModelTest { } assertFalse(settingsViewModel.stateFlow.value.fileListVisible) } + + @Test + fun biometricEnableDisableLifecycle() = runTest { + Mockito.`when`(mockBiometricSettingsGateway.isSupported()).thenReturn(true) + Mockito.`when`(mockBiometricSettingsGateway.isEnabled()).thenReturn(false) + settingsViewModel.updateSwitches() + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ToggleBiometric(true)) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + Mockito.verify(mockBiometricSettingsGateway).setEnabled(true) + assertTrue(settingsViewModel.stateFlow.value.biometricEnabled) + + settingsViewModel.onAction(SettingsAction.ToggleBiometric(false)) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + Mockito.verify(mockBiometricSettingsGateway).setEnabled(false) + assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) + } + + @Test + fun biometricUnsupportedDeviceBehavior() = runTest { + Mockito.`when`(mockBiometricSettingsGateway.isSupported()).thenReturn(false) + + settingsViewModel.onAction(SettingsAction.ToggleBiometric(true)) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(settingsViewModel.stateFlow.value.biometricSupported) + assertFalse(settingsViewModel.stateFlow.value.biometricEnabled) + Mockito.verify(mockBiometricSettingsGateway, Mockito.never()).setEnabled(true) + } } 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 f8bae95b2..cb3343922 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 @@ -29,12 +29,18 @@ class SignInViewModelTest { private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) private val mockRouter = Mockito.mock(Router::class.java) private val mockAutofillManager = Mockito.mock(AutofillManager::class.java) + private val mockBiometricAuthenticator = Mockito.mock(BiometricAuthenticator::class.java) private lateinit var signInViewModel: SignInViewModel @Before fun setUp() { - signInViewModel = SignInViewModel(mockCheckPasswordUseCase, mockRouter) + Mockito.`when`(mockBiometricAuthenticator.isAvailable()).thenReturn(false) + signInViewModel = SignInViewModel( + checkPasswordUseCase = mockCheckPasswordUseCase, + router = mockRouter, + biometricAuthenticator = mockBiometricAuthenticator, + ) signInViewModel.autofillManager = mockAutofillManager } @@ -114,4 +120,53 @@ class SignInViewModelTest { cancelAndIgnoreRemainingEvents() } } -} \ No newline at end of file + + @Test + fun biometricAvailableSuccess() = runTest { + Mockito.`when`(mockBiometricAuthenticator.isAvailable()).thenReturn(true) + Mockito.`when`(mockBiometricAuthenticator.authenticate()).thenReturn(BiometricAuthResult.Success) + + signInViewModel.onAction(SignInAction.OnBiometricSignInClick) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + Mockito.verify(mockAutofillManager).commit() + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) + } + + @Test + fun biometricAvailableUserCancel() = runTest { + Mockito.`when`(mockBiometricAuthenticator.isAvailable()).thenReturn(true) + Mockito.`when`(mockBiometricAuthenticator.authenticate()).thenReturn(BiometricAuthResult.Cancelled) + + signInViewModel.onAction(SignInAction.OnBiometricSignInClick) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + Mockito.verifyNoInteractions(mockRouter) + } + + @Test + fun biometricHardFailure() = runTest { + val throwable = IllegalStateException("auth failed") + Mockito.`when`(mockBiometricAuthenticator.isAvailable()).thenReturn(true) + Mockito.`when`(mockBiometricAuthenticator.authenticate()).thenReturn(BiometricAuthResult.Error(throwable)) + + signInViewModel.onAction(SignInAction.OnBiometricSignInClick) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + Mockito.verify(mockAutofillManager).cancel() + Mockito.verify(mockRouter).navigate(route = AppNavGraph.ErrorDialog(message = throwable.message)) + } + + @Test + fun biometricUnavailableFallbackToPassword() = runTest { + Mockito.`when`(mockBiometricAuthenticator.isAvailable()).thenReturn(false) + signInViewModel.onAction(SignInAction.OnBiometricSignInClick) + + val pass = StubEditable("pass") + Mockito.`when`(mockCheckPasswordUseCase(pass)).thenReturn(true) + signInViewModel.onAction(SignInAction.OnSignInClick(pass)) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + Mockito.verify(mockRouter).navigateClearingBackStack(route = AppNavGraph.Main) + } +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/BiometricSettingsGateway.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/BiometricSettingsGateway.kt new file mode 100644 index 000000000..dc8d429c9 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/BiometricSettingsGateway.kt @@ -0,0 +1,13 @@ +package com.softartdev.notedelight.presentation.settings + +interface BiometricSettingsGateway { + fun isSupported(): Boolean + fun isEnabled(): Boolean + fun setEnabled(enabled: Boolean) +} + +object NoOpBiometricSettingsGateway : BiometricSettingsGateway { + override fun isSupported(): Boolean = false + override fun isEnabled(): Boolean = false + override fun setEnabled(enabled: Boolean) = Unit +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsResult.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsResult.kt index d9ddfd2e1..ef5919262 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsResult.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsResult.kt @@ -7,6 +7,8 @@ data class SettingsResult( val loading: Boolean = false, val encryption: Boolean = false, val fileListVisible: Boolean = false, + val biometricSupported: Boolean = false, + val biometricEnabled: Boolean = false, val language: LanguageEnum = LanguageEnum.ENGLISH, val appVersion: String? = null, val selectedCategory: SettingsCategory? = null, @@ -28,6 +30,7 @@ sealed interface SettingsAction { data object ShowDatabasePath : SettingsAction data class ExportDatabase(val destinationPath: String?) : SettingsAction data class ImportDatabase(val sourcePath: String?) : SettingsAction + data class ToggleBiometric(val enabled: Boolean) : SettingsAction data object ShowFileList : SettingsAction data object RevealFileList : SettingsAction } 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 b0a959f22..fe35a391d 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 @@ -38,6 +38,7 @@ class SettingsViewModel( private val localeInteractor: LocaleInteractor, private val adaptiveInteractor: AdaptiveInteractor, private val coroutineDispatchers: CoroutineDispatchers, + private val biometricSettingsGateway: BiometricSettingsGateway = NoOpBiometricSettingsGateway, ) : ViewModel() { private val logger = Logger.withTag(this@SettingsViewModel::class.simpleName.toString()) private val mutableStateFlow: MutableStateFlow = MutableStateFlow( @@ -67,6 +68,7 @@ class SettingsViewModel( is SettingsAction.ShowDatabasePath -> showDatabasePath() is SettingsAction.ExportDatabase -> exportDatabase(action.destinationPath) is SettingsAction.ImportDatabase -> importDatabase(action.sourcePath) + is SettingsAction.ToggleBiometric -> toggleBiometric(action.enabled) is SettingsAction.ShowFileList -> showFileList() is SettingsAction.RevealFileList -> revealFileList() } @@ -78,6 +80,9 @@ class SettingsViewModel( mutableStateFlow.update { result -> result.copy( encryption = dbIsEncrypted, + biometricSupported = biometricSettingsGateway.isSupported(), + biometricEnabled = biometricSettingsGateway.isSupported() && + biometricSettingsGateway.isEnabled(), language = localeInteractor.languageEnum, appVersion = appVersionUseCase.invoke() ) @@ -221,6 +226,15 @@ class SettingsViewModel( private fun showFileList() = router.navigate(route = AppNavGraph.FileList) + private fun toggleBiometric(enabled: Boolean) = viewModelScope.launch { + if (!biometricSettingsGateway.isSupported()) { + mutableStateFlow.update { it.copy(biometricSupported = false, biometricEnabled = false) } + return@launch + } + biometricSettingsGateway.setEnabled(enabled) + mutableStateFlow.update { it.copy(biometricSupported = true, biometricEnabled = enabled) } + } + private fun revealFileList() { if (mutableStateFlow.value.fileListVisible) return revealFileListUseCase.onTap(viewModelScope) { diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/BiometricAuthenticator.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/BiometricAuthenticator.kt new file mode 100644 index 000000000..ea1f25fc9 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/BiometricAuthenticator.kt @@ -0,0 +1,18 @@ +package com.softartdev.notedelight.presentation.signin + +interface BiometricAuthenticator { + fun isAvailable(): Boolean + suspend fun authenticate(): BiometricAuthResult +} + +sealed interface BiometricAuthResult { + data object Success : BiometricAuthResult + data object Cancelled : BiometricAuthResult + data class Error(val throwable: Throwable) : BiometricAuthResult +} + +object NoOpBiometricAuthenticator : BiometricAuthenticator { + override fun isAvailable(): Boolean = false + + override suspend fun authenticate(): BiometricAuthResult = BiometricAuthResult.Cancelled +} diff --git a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInAction.kt b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInAction.kt index c26afadf5..73ffd1c38 100644 --- a/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInAction.kt +++ b/core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/signin/SignInAction.kt @@ -3,4 +3,5 @@ package com.softartdev.notedelight.presentation.signin sealed interface SignInAction { data object OnSettingsClick : SignInAction data class OnSignInClick(val pass: CharSequence) : SignInAction + data object OnBiometricSignInClick : SignInAction } 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 fde142e82..a04dda290 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 @@ -14,7 +14,8 @@ import kotlinx.coroutines.launch class SignInViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, - private val router: Router + private val router: Router, + private val biometricAuthenticator: BiometricAuthenticator = NoOpBiometricAuthenticator, ) : ViewModel() { private val logger = Logger.withTag(this@SignInViewModel::class.simpleName.toString()) private val mutableStateFlow: MutableStateFlow = MutableStateFlow( @@ -22,10 +23,13 @@ class SignInViewModel( ) val stateFlow: StateFlow = mutableStateFlow var autofillManager: AutofillManager? = null + val isBiometricAvailable: Boolean + get() = biometricAuthenticator.isAvailable() fun onAction(action: SignInAction) = when (action) { is SignInAction.OnSettingsClick -> router.navigateClearingBackStack(AppNavGraph.Settings) is SignInAction.OnSignInClick -> signIn(action.pass) + is SignInAction.OnBiometricSignInClick -> signInWithBiometric() } private fun signIn(pass: CharSequence) = viewModelScope.launch { @@ -50,4 +54,27 @@ class SignInViewModel( CountingIdlingRes.decrement() } } + + private fun signInWithBiometric() = viewModelScope.launch { + if (!biometricAuthenticator.isAvailable()) return@launch + CountingIdlingRes.increment() + mutableStateFlow.value = SignInResult.ShowProgress + try { + when (val result = biometricAuthenticator.authenticate()) { + BiometricAuthResult.Success -> { + autofillManager?.commit() + router.navigateClearingBackStack(AppNavGraph.Main) + } + BiometricAuthResult.Cancelled -> Unit + is BiometricAuthResult.Error -> { + logger.e(result.throwable) { "Error during biometric sign in" } + autofillManager?.cancel() + router.navigate(route = AppNavGraph.ErrorDialog(message = result.throwable.message)) + } + } + } finally { + mutableStateFlow.value = SignInResult.ShowSignInForm + CountingIdlingRes.decrement() + } + } } 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 582f82b28..85f01947b 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 @@ -5,6 +5,7 @@ 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_BUTTON_TAG +import com.softartdev.notedelight.util.SIGN_IN_BIOMETRIC_BUTTON_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_FIELD_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_LABEL_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_VISIBILITY_TAG @@ -32,4 +33,8 @@ 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) +} 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 f02a0a16b..6b6017879 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 @@ -7,12 +7,16 @@ import com.softartdev.notedelight.presentation.note.DeleteViewModel import com.softartdev.notedelight.presentation.note.NoteViewModel import com.softartdev.notedelight.presentation.note.SaveViewModel import com.softartdev.notedelight.presentation.settings.LanguageViewModel +import com.softartdev.notedelight.presentation.settings.BiometricSettingsGateway +import com.softartdev.notedelight.presentation.settings.NoOpBiometricSettingsGateway 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.change.ChangeViewModel import com.softartdev.notedelight.presentation.settings.security.confirm.ConfirmViewModel import com.softartdev.notedelight.presentation.settings.security.enter.EnterViewModel +import com.softartdev.notedelight.presentation.signin.BiometricAuthenticator +import com.softartdev.notedelight.presentation.signin.NoOpBiometricAuthenticator import com.softartdev.notedelight.presentation.signin.SignInViewModel import com.softartdev.notedelight.presentation.splash.SplashViewModel import com.softartdev.notedelight.presentation.title.EditTitleViewModel @@ -49,6 +53,8 @@ val daoModule: Module = module { } val useCaseModule: Module = module { + factory { NoOpBiometricAuthenticator } + factory { NoOpBiometricSettingsGateway } factoryOf(::ChangePasswordUseCase) factoryOf(::CheckPasswordUseCase) factoryOf(::CheckSqlCipherVersionUseCase) 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 e6fc9572f..85b814c93 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator @@ -35,6 +36,7 @@ import com.softartdev.notedelight.presentation.signin.SignInViewModel import com.softartdev.notedelight.ui.PasswordField import com.softartdev.notedelight.ui.TooltipIconButton import com.softartdev.notedelight.util.SIGN_IN_BUTTON_TAG +import com.softartdev.notedelight.util.SIGN_IN_BIOMETRIC_BUTTON_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_FIELD_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_LABEL_TAG import com.softartdev.notedelight.util.SIGN_IN_PASSWORD_VISIBILITY_TAG @@ -66,7 +68,8 @@ fun SignInScreen(signInViewModel: SignInViewModel) { else -> Res.string.enter_password }, isError = signInResultState.value.isError, - onAction = signInViewModel::onAction + onAction = signInViewModel::onAction, + showBiometricButton = signInViewModel.isBiometricAvailable, ) } @@ -77,6 +80,7 @@ fun SignInScreenBody( passwordState: MutableState = mutableStateOf("password"), labelResource: StringResource = Res.string.enter_password, isError: Boolean = false, + showBiometricButton: Boolean = false, onAction: (SignInAction) -> Unit = {}, ) = Scaffold( topBar = { @@ -117,10 +121,21 @@ fun SignInScreenBody( .padding(top = 24.dp), onClick = { onAction(SignInAction.OnSignInClick(passwordState.value)) }, ) { Text(text = stringResource(Res.string.sign_in)) } + if (showBiometricButton) { + OutlinedButton( + modifier = Modifier + .testTag(SIGN_IN_BIOMETRIC_BUTTON_TAG) + .fillMaxWidth() + .padding(top = 12.dp), + onClick = { onAction(SignInAction.OnBiometricSignInClick) }, + ) { + Text(text = "Use biometric") + } + } } } } @Preview @Composable -fun PreviewSignInScreen() = SignInScreenBody() \ No newline at end of file +fun PreviewSignInScreen() = SignInScreenBody() 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 d44e30a2a..76e8f38dc 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 @@ -8,6 +8,7 @@ const val SIGN_IN_PASSWORD_VISIBILITY_TAG = "SIGN_IN_PASSWORD_FIELD_VISIBILITY_T const val SIGN_IN_PASSWORD_FIELD_TAG = "SIGN_IN_PASSWORD_FIELD_TAG" const val SIGN_IN_SETTINGS_BUTTON_TAG = "SIGN_IN_SETTINGS_BUTTON_TAG" const val SIGN_IN_BUTTON_TAG = "SIGN_IN_BUTTON_TAG" +const val SIGN_IN_BIOMETRIC_BUTTON_TAG = "SIGN_IN_BIOMETRIC_BUTTON_TAG" /** * Test tags for [com.softartdev.notedelight.ui.dialog.security.EnterPasswordDialog] diff --git a/core/ui/src/jvmTest/kotlin/com/softartdev/notedelight/ui/signin/SignInScreenBiometricTest.kt b/core/ui/src/jvmTest/kotlin/com/softartdev/notedelight/ui/signin/SignInScreenBiometricTest.kt new file mode 100644 index 000000000..f38e11bde --- /dev/null +++ b/core/ui/src/jvmTest/kotlin/com/softartdev/notedelight/ui/signin/SignInScreenBiometricTest.kt @@ -0,0 +1,57 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.softartdev.notedelight.ui.signin + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import com.softartdev.notedelight.presentation.signin.SignInAction +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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SignInScreenBiometricTest { + + @Test + fun biometricButtonVisibilityDependsOnAvailability() = runComposeUiTest { + setContent { + SignInScreenBody(showLoading = false, showBiometricButton = true) + } + onNodeWithTag(SIGN_IN_BIOMETRIC_BUTTON_TAG).assertIsDisplayed() + + setContent { + SignInScreenBody(showLoading = false, showBiometricButton = false) + } + onNodeWithTag(SIGN_IN_BIOMETRIC_BUTTON_TAG).assertDoesNotExist() + } + + @Test + fun fallbackPathPreservesPasswordEntryUx() = runComposeUiTest { + val passwordState = mutableStateOf("") + val actions = mutableListOf() + setContent { + SignInScreenBody( + showLoading = false, + showBiometricButton = true, + passwordState = passwordState, + onAction = { action -> actions += action } + ) + } + + onNodeWithTag(SIGN_IN_PASSWORD_FIELD_TAG, useUnmergedTree = true).performTextInput("pass123") + onNodeWithTag(SIGN_IN_BIOMETRIC_BUTTON_TAG).performClick() + assertEquals("pass123", passwordState.value) + + onNodeWithTag(SIGN_IN_BUTTON_TAG).performClick() + assertTrue(actions.contains(SignInAction.OnBiometricSignInClick)) + assertTrue(actions.contains(SignInAction.OnSignInClick("pass123"))) + } +}