Skip to content

Commit 723b512

Browse files
committed
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.
1 parent e90875a commit 723b512

28 files changed

Lines changed: 394 additions & 128 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
3+
package com.softartdev.notedelight.ui
4+
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
7+
import androidx.test.espresso.Espresso
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.filters.FlakyTest
10+
import com.softartdev.notedelight.MainActivity
11+
import com.softartdev.notedelight.di.biometricTestModule
12+
import com.softartdev.notedelight.interactor.TestBiometricInteractor
13+
import com.softartdev.notedelight.reflect
14+
import com.softartdev.notedelight.ui.cases.BiometricSettingsTestCase
15+
import leakcanary.DetectLeaksAfterTestSuccess
16+
import leakcanary.TestDescriptionHolder
17+
import org.junit.After
18+
import org.junit.Rule
19+
import org.junit.Test
20+
import org.junit.rules.RuleChain
21+
import org.junit.runner.RunWith
22+
import org.koin.core.context.loadKoinModules
23+
import org.koin.mp.KoinPlatformTools
24+
25+
@FlakyTest
26+
@RunWith(AndroidJUnit4::class)
27+
class BiometricSettingsTest {
28+
29+
private val testBiometricInteractor: TestBiometricInteractor
30+
get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class)
31+
32+
private val composeTestRule = customAndroidComposeRule<MainActivity>(
33+
beforeActivityLaunched = {
34+
loadKoinModules(biometricTestModule)
35+
testBiometricInteractor.reset(canAuthenticateResult = true)
36+
}
37+
)
38+
39+
@get:Rule
40+
val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder)
41+
.around(DetectLeaksAfterTestSuccess())
42+
.around(composeTestRule)
43+
44+
private val composeUiTest: ComposeUiTest = reflect(composeTestRule)
45+
46+
@After
47+
fun tearDown() = testBiometricInteractor.reset()
48+
49+
@Test
50+
fun biometricSettingsTest() = BiometricSettingsTestCase(
51+
composeUiTest = composeUiTest,
52+
closeSoftKeyboard = Espresso::closeSoftKeyboard,
53+
).invoke()
54+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
3+
package com.softartdev.notedelight.ui
4+
5+
import androidx.compose.ui.test.ComposeUiTest
6+
import androidx.compose.ui.test.ExperimentalTestApi
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import androidx.test.filters.FlakyTest
9+
import com.softartdev.notedelight.DbTestEncryptor
10+
import com.softartdev.notedelight.MainActivity
11+
import com.softartdev.notedelight.di.biometricTestModule
12+
import com.softartdev.notedelight.interactor.TestBiometricInteractor
13+
import com.softartdev.notedelight.reflect
14+
import com.softartdev.notedelight.ui.cases.BiometricSignInTestCase
15+
import leakcanary.DetectLeaksAfterTestSuccess
16+
import leakcanary.TestDescriptionHolder
17+
import org.junit.After
18+
import org.junit.Rule
19+
import org.junit.Test
20+
import org.junit.rules.RuleChain
21+
import org.junit.runner.RunWith
22+
import org.koin.core.context.loadKoinModules
23+
import org.koin.mp.KoinPlatformTools
24+
25+
@FlakyTest
26+
@RunWith(AndroidJUnit4::class)
27+
class BiometricSignInTest {
28+
29+
private val testBiometricInteractor: TestBiometricInteractor
30+
get() = KoinPlatformTools.defaultContext().get().get(TestBiometricInteractor::class)
31+
32+
private val composeTestRule = customAndroidComposeRule<MainActivity>(
33+
beforeActivityLaunched = {
34+
loadKoinModules(biometricTestModule)
35+
testBiometricInteractor.reset(
36+
canAuthenticateResult = true,
37+
storedPassword = DbTestEncryptor.PASSWORD,
38+
)
39+
DbTestEncryptor()
40+
}
41+
)
42+
43+
@get:Rule
44+
val rules: RuleChain = RuleChain.outerRule(TestDescriptionHolder)
45+
.around(DetectLeaksAfterTestSuccess())
46+
.around(composeTestRule)
47+
48+
private val composeUiTest: ComposeUiTest = reflect(composeTestRule)
49+
50+
@After
51+
fun tearDown() = testBiometricInteractor.reset()
52+
53+
@Test
54+
fun biometricSignInTest() = BiometricSignInTestCase(composeUiTest = composeUiTest).invoke()
55+
}

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/adaptive/AdaptiveInteractorTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import com.softartdev.notedelight.presentation.settings.SettingsCategoriesAction
2727
import com.softartdev.notedelight.presentation.settings.SettingsCategoriesViewModel
2828
import com.softartdev.notedelight.presentation.settings.SettingsViewModel
2929
import com.softartdev.notedelight.repository.SafeRepo
30-
import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase
3130
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
3231
import com.softartdev.notedelight.usecase.note.CreateNoteUseCase
3332
import com.softartdev.notedelight.usecase.note.DeleteNoteUseCase
@@ -119,7 +118,6 @@ class AdaptiveInteractorTest {
119118
snackbarInteractor = mockSnackbarInteractor,
120119
router = mockRouter,
121120
revealFileListUseCase = revealFileListUseCase,
122-
disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor),
123121
localeInteractor = mockLocaleInteractor,
124122
adaptiveInteractor = adaptiveInteractor,
125123
biometricInteractor = mockBiometricInteractor,

core/presentation/src/androidHostTest/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModelTest.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import com.softartdev.notedelight.navigation.AppNavGraph
1616
import com.softartdev.notedelight.navigation.Router
1717
import com.softartdev.notedelight.presentation.MainDispatcherRule
1818
import com.softartdev.notedelight.repository.SafeRepo
19-
import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase
2019
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
2120
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
2221
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
@@ -61,7 +60,6 @@ class SettingsViewModelTest {
6160
snackbarInteractor = mockSnackbarInteractor,
6261
router = mockRouter,
6362
revealFileListUseCase = RevealFileListUseCase(),
64-
disableBiometricUseCase = DisableBiometricUseCase(mockBiometricInteractor),
6563
localeInteractor = mockLocaleInteractor,
6664
adaptiveInteractor = adaptiveInteractor,
6765
biometricInteractor = mockBiometricInteractor,
@@ -184,7 +182,7 @@ class SettingsViewModelTest {
184182
settingsViewModel.onAction(SettingsAction.ChangeBiometric(false))
185183
Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog)
186184

187-
DisableBiometricUseCase.dialogChannel.send(false)
185+
BiometricInteractor.disableDialogChannel.send(false)
188186
mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle()
189187

190188
assertTrue(settingsViewModel.stateFlow.value.biometricEnabled)
@@ -203,7 +201,7 @@ class SettingsViewModelTest {
203201
settingsViewModel.onAction(SettingsAction.ChangeBiometric(false))
204202
Mockito.verify(mockRouter).navigate(route = AppNavGraph.BiometricDisableConfirmationDialog)
205203

206-
DisableBiometricUseCase.dialogChannel.send(true)
204+
BiometricInteractor.disableDialogChannel.send(true)
207205
mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle()
208206

209207
assertFalse(settingsViewModel.stateFlow.value.biometricEnabled)

core/presentation/src/commonMain/kotlin/com/softartdev/notedelight/presentation/settings/SettingsViewModel.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import com.softartdev.notedelight.model.SettingsCategory
1313
import com.softartdev.notedelight.navigation.AppNavGraph
1414
import com.softartdev.notedelight.navigation.Router
1515
import com.softartdev.notedelight.repository.SafeRepo
16-
import com.softartdev.notedelight.usecase.biometric.DisableBiometricUseCase
1716
import com.softartdev.notedelight.usecase.crypt.CheckSqlCipherVersionUseCase
1817
import com.softartdev.notedelight.usecase.settings.AppVersionUseCase
1918
import com.softartdev.notedelight.usecase.settings.ExportDatabaseUseCase
@@ -37,7 +36,6 @@ class SettingsViewModel(
3736
private val snackbarInteractor: SnackbarInteractor,
3837
private val router: Router,
3938
private val revealFileListUseCase: RevealFileListUseCase,
40-
private val disableBiometricUseCase: DisableBiometricUseCase,
4139
private val localeInteractor: LocaleInteractor,
4240
private val adaptiveInteractor: AdaptiveInteractor,
4341
private val biometricInteractor: BiometricInteractor,
@@ -148,11 +146,11 @@ class SettingsViewModel(
148146
} else {
149147
router.navigate(route = AppNavGraph.BiometricDisableConfirmationDialog)
150148
val disableBiometric: Boolean = withContext(coroutineDispatchers.io) {
151-
DisableBiometricUseCase.dialogChannel.receive()
149+
BiometricInteractor.disableDialogChannel.receive()
152150
}
153151
if (disableBiometric) {
154152
withContext(coroutineDispatchers.io) {
155-
disableBiometricUseCase()
153+
biometricInteractor.clearStoredPassword()
156154
}
157155
mutableStateFlow.update { it.copy(biometricEnabled = false) }
158156
} else {

core/test/ui/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ kotlin {
4646
implementation(projects.core.presentation)
4747
implementation(projects.core.ui)
4848
implementation(projects.feature.backup.ui)
49+
api(projects.feature.biometric.domain)
4950
implementation(projects.feature.console.presentation)
5051
implementation(projects.feature.console.ui)
5152
implementation(libs.compose.ui.test)
@@ -95,4 +96,4 @@ kotlin {
9596
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
9697
}
9798

98-
project.disableIosReleaseTasks()
99+
project.disableIosReleaseTasks()

core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/di/uiTestModules.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.softartdev.notedelight.di
22

33
import com.softartdev.notedelight.UiThreadRouter
4+
import com.softartdev.notedelight.interactor.BiometricInteractor
5+
import com.softartdev.notedelight.interactor.TestBiometricInteractor
46
import com.softartdev.notedelight.navigation.Router
57
import com.softartdev.notedelight.navigation.RouterImpl
68
import com.softartdev.notedelight.ui.settings.detail.DatabaseFilePicker
@@ -9,7 +11,7 @@ import org.koin.core.module.Module
911
import org.koin.dsl.module
1012

1113
val uiTestModules: List<Module>
12-
get() = listOf(navigationTestModule, interactorModule, utilModule, backupTestModule)
14+
get() = listOf(navigationTestModule, interactorModule, utilModule, backupTestModule, biometricTestModule)
1315

1416
val navigationTestModule = module {
1517
single<Router> { UiThreadRouter(router = RouterImpl()) }
@@ -18,3 +20,8 @@ val navigationTestModule = module {
1820
val backupTestModule = module {
1921
single<DatabaseFilePicker> { TestDatabaseFilePicker() }
2022
}
23+
24+
val biometricTestModule = module {
25+
single { TestBiometricInteractor() }
26+
single<BiometricInteractor> { get<TestBiometricInteractor>() }
27+
}

core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ext.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import co.touchlab.kermit.Logger
1515
const val ASSERT_WAIT_TIMEOUT_MILLIS: Long = 20_000
1616
const val MAX_RETRY_ATTEMPTS = 100
1717

18-
inline fun retryUntilDisplayed(
18+
fun retryUntilDisplayed(
1919
description: String,
2020
action: () -> Unit,
2121
sni: SemanticsNodeInteraction,
@@ -73,4 +73,3 @@ fun ComposeUiTest.waitUntilSelected(
7373
}
7474
return@waitUntil true
7575
}
76-
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.softartdev.notedelight.interactor
2+
3+
class TestBiometricInteractor : BiometricInteractor {
4+
var canAuthenticateResult: Boolean = false
5+
private set
6+
var storedPassword: CharSequence? = null
7+
private set
8+
var encryptResult: BiometricResult = BiometricResult.Success
9+
var decryptResult: DecryptedPasswordResult? = null
10+
var clearStoredPasswordCount: Int = 0
11+
private set
12+
13+
fun reset(
14+
canAuthenticateResult: Boolean = false,
15+
storedPassword: CharSequence? = null,
16+
) {
17+
this.canAuthenticateResult = canAuthenticateResult
18+
this.storedPassword = storedPassword
19+
encryptResult = BiometricResult.Success
20+
decryptResult = null
21+
clearStoredPasswordCount = 0
22+
}
23+
24+
override suspend fun canAuthenticate(): Boolean = canAuthenticateResult
25+
26+
override suspend fun hasStoredPassword(): Boolean = storedPassword != null
27+
28+
override suspend fun encryptAndStorePassword(
29+
password: CharSequence,
30+
title: String,
31+
subtitle: String,
32+
negativeButton: String,
33+
biometricPlatformWrapper: BiometricPlatformWrapper,
34+
): BiometricResult {
35+
if (encryptResult == BiometricResult.Success) {
36+
storedPassword = password.toString()
37+
}
38+
return encryptResult
39+
}
40+
41+
override suspend fun decryptStoredPassword(
42+
title: String,
43+
subtitle: String,
44+
negativeButton: String,
45+
biometricPlatformWrapper: BiometricPlatformWrapper,
46+
): DecryptedPasswordResult = decryptResult
47+
?: storedPassword?.let { DecryptedPasswordResult.Success(it) }
48+
?: DecryptedPasswordResult.Unavailable
49+
50+
override suspend fun clearStoredPassword() {
51+
clearStoredPasswordCount++
52+
storedPassword = null
53+
}
54+
}

core/test/ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/BaseTestCase.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.softartdev.notedelight.ui.screen.NoteScreen
99
import com.softartdev.notedelight.ui.screen.SettingsTestScreen
1010
import com.softartdev.notedelight.ui.screen.SignInScreen
1111
import com.softartdev.notedelight.ui.screen.dialog.BiometricDisableConfirmationDialog
12+
import com.softartdev.notedelight.ui.screen.dialog.BiometricEnrollDialog
1213
import com.softartdev.notedelight.ui.screen.dialog.ChangePasswordDialog
1314
import com.softartdev.notedelight.ui.screen.dialog.CommonDialog
1415
import com.softartdev.notedelight.ui.screen.dialog.CommonDialogImpl
@@ -56,6 +57,9 @@ abstract class BaseTestCase(val composeUiTest: ComposeUiTest) {
5657
block: suspend BiometricDisableConfirmationDialog.() -> Unit,
5758
) = BiometricDisableConfirmationDialog(commonDialog).block()
5859

60+
suspend inline fun biometricEnrollDialog(block: suspend BiometricEnrollDialog.() -> Unit) =
61+
BiometricEnrollDialog(commonDialog).block()
62+
5963
suspend inline fun languageDialog(block: suspend LanguageDialog.() -> Unit) =
6064
LanguageDialog(commonDialog).block()
6165
}

0 commit comments

Comments
 (0)