Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -59,6 +60,7 @@ class SettingsViewModelTest {
localeInteractor = mockLocaleInteractor,
adaptiveInteractor = adaptiveInteractor,
coroutineDispatchers = coroutineDispatchers,
biometricSettingsGateway = mockBiometricSettingsGateway,
)

@After
Expand All @@ -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)
Expand All @@ -91,6 +95,8 @@ class SettingsViewModelTest {
result = awaitItem()
}
assertTrue(result.encryption)
assertTrue(result.biometricSupported)
assertTrue(result.biometricEnabled)
cancelAndIgnoreRemainingEvents()
}
Mockito.verifyNoMoreInteractions(mockRouter)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -114,4 +120,53 @@ class SignInViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
}

@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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsResult> = MutableStateFlow(
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@ 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<SignInResult> = MutableStateFlow(
value = SignInResult.ShowSignInForm
)
val stateFlow: StateFlow<SignInResult> = 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 {
Expand All @@ -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()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Catch exceptions from biometric authentication

signInWithBiometric() only handles BiometricAuthResult.Error, but biometricAuthenticator.authenticate() itself is not wrapped in a catch. If a platform authenticator throws (for example, prompt/runtime failures), the exception escapes viewModelScope and can crash the sign-in flow instead of showing the existing error dialog path used by password sign-in. Please catch thrown exceptions around authenticate() and route them through the same error handling path.

Useful? React with 👍 / 👎.

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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,4 +33,8 @@ value class SignInScreen(val nodeProvider: SemanticsNodeInteractionsProvider) {
get() = nodeProvider
.onNodeWithTag(SIGN_IN_BUTTON_TAG)
.assertIsDisplayed()
}

val biometricButtonSNI: SemanticsNodeInteraction
get() = nodeProvider
.onNodeWithTag(SIGN_IN_BIOMETRIC_BUTTON_TAG)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +53,8 @@ val daoModule: Module = module {
}

val useCaseModule: Module = module {
factory<BiometricAuthenticator> { NoOpBiometricAuthenticator }
factory<BiometricSettingsGateway> { NoOpBiometricSettingsGateway }
factoryOf(::ChangePasswordUseCase)
factoryOf(::CheckPasswordUseCase)
factoryOf(::CheckSqlCipherVersionUseCase)
Expand Down
Loading
Loading