Skip to content
Merged
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
@@ -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<MainActivity>(
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()
}
Original file line number Diff line number Diff line change
@@ -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<MainActivity>(
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ 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.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,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)

BiometricInteractor.disableDialogChannel.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)

BiometricInteractor.disableDialogChannel.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)
Expand Down Expand Up @@ -265,4 +311,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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -91,7 +93,7 @@ class SignInViewModelTest {
assertEquals(SignInResult(), awaitItem())

signInViewModel.onAction(SignInAction.OnSignInClick(pass = StubEditable("")))
assertTrue(awaitItem().state is SignInResult.State.Error.EmptyPass)
assertEquals(SignInResult.ErrorType.EMPTY_PASSWORD, awaitItem().errorType)

cancelAndIgnoreRemainingEvents()
}
Expand All @@ -105,7 +107,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(SignInResult.ErrorType.INCORRECT_PASSWORD, awaitItem().errorType)

cancelAndIgnoreRemainingEvents()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,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) {
BiometricInteractor.disableDialogChannel.receive()
}
if (disableBiometric) {
withContext(coroutineDispatchers.io) {
biometricInteractor.clearStoredPassword()
}
mutableStateFlow.update { it.copy(biometricEnabled = false) }
} else {
logger.d { "Don't disable biometric" }
}
}
} catch (e: Throwable) {
handleError(e) { "error toggling biometric" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
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
enum class ErrorType { EMPTY_PASSWORD, INCORRECT_PASSWORD }

data object Progress : State

sealed interface Error : State {
data object EmptyPass : Error

data object IncorrectPass : Error
}
}
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,64 +55,57 @@ class SignInViewModel(
biometricPlatformWrapper: BiometricPlatformWrapper,
) = viewModelScope.launch {
CountingIdlingRes.increment()
mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) }
mutableStateFlow.update(SignInResult::hideErrors)
mutableStateFlow.update(SignInResult::showLoading)
try {
when (val res: DecryptedPasswordResult = biometricInteractor.decryptStoredPassword(
title = title,
subtitle = subtitle,
negativeButton = negativeButton,
biometricPlatformWrapper = biometricPlatformWrapper,
)) {
is DecryptedPasswordResult.Success -> mutableStateFlow.update {
it.copy(state = signInInternal(res.password))
}
is DecryptedPasswordResult.Cancelled -> mutableStateFlow.update {
it.copy(state = SignInResult.State.Form)
}
is DecryptedPasswordResult.Success -> signInInternal(pass = res.password)
is DecryptedPasswordResult.Cancelled -> Unit
is DecryptedPasswordResult.Unavailable -> {
biometricInteractor.clearStoredPassword()
mutableStateFlow.update {
it.copy(state = SignInResult.State.Form, biometricVisible = false)
}
mutableStateFlow.update(SignInResult::hideBiometric)
}
is DecryptedPasswordResult.Failure -> {
logger.e { res.message }
snackbarInteractor.showMessage(SnackbarMessage.Simple(res.message))
mutableStateFlow.update { it.copy(state = SignInResult.State.Form) }
}
}
} 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) }
} finally {
mutableStateFlow.update(SignInResult::hideLoading)
CountingIdlingRes.decrement()
}
}

private fun signIn(pass: CharSequence) = viewModelScope.launch {
CountingIdlingRes.increment()
mutableStateFlow.update { it.copy(state = SignInResult.State.Progress) }
mutableStateFlow.update(SignInResult::hideErrors)
mutableStateFlow.update(SignInResult::showLoading)
try {
val nextState: SignInResult.State = signInInternal(pass)
mutableStateFlow.update { it.copy(state = 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(state = SignInResult.State.Form) }
} finally {
mutableStateFlow.update(SignInResult::hideLoading)
CountingIdlingRes.decrement()
}
}

private suspend fun signInInternal(pass: CharSequence): SignInResult.State = when {
pass.isEmpty() -> SignInResult.State.Error.EmptyPass
private suspend fun signInInternal(pass: CharSequence) = when {
pass.isEmpty() -> mutableStateFlow.update(SignInResult::showEmptyPasswordError)
checkPasswordUseCase(pass) -> {
autofillManager?.commit()
router.navigateClearingBackStack(AppNavGraph.Main)
SignInResult.State.Form
}
else -> SignInResult.State.Error.IncorrectPass
else -> mutableStateFlow.update(SignInResult::showIncorrectPasswordError)
}
}
3 changes: 2 additions & 1 deletion core/test/ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -95,4 +96,4 @@ kotlin {
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
}

project.disableIosReleaseTasks()
project.disableIosReleaseTasks()
Loading
Loading